From f6ef02f05d6490a24cc54e3e356feeadecb79138 Mon Sep 17 00:00:00 2001 From: CoinThrow <108105682+CoinThrow@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:30:45 +0200 Subject: [PATCH 1/3] fix(AscRuntime): register the three measured callback lists by captured order Aura application, effect filtering and visual hiding still inserted by call site, which does not reproduce the original: registration execution order does not follow the call sites (the aura list registers AscSpellMods before AscSpellVisuals, AscSpawnVisibility before AscAppearance). Measured the three lists from the original's live list links in two independent game sessions; they now rank by the captured site order (InsertByCapturedOrder), like the two lists added earlier. The aura-application list carries one original-only sixth callback (runtime-registered, no static call site, not reconstructed) that runs after the five captured entries. Fixture, portable test (all registration permutations, vetoes, duplicates, an off-array unknown site, visual-hide consumer short-circuit) and docs/CALLBACK-ORDER-MEASURED.md extended; 3367 checks, 0 failures locally. --- README.md | 21 +++-- docs/CALLBACK-ORDER-MEASURED.md | 60 +++++++++++++ src/Ascension/AscRuntime.cpp | 24 ++++- tests/fixtures/original_callback_order.json | 30 ++++++- tests/native_callback_order.py | 98 ++++++++++++++++++++- 5 files changed, 217 insertions(+), 16 deletions(-) create mode 100644 docs/CALLBACK-ORDER-MEASURED.md diff --git a/README.md b/README.md index 7646873..852edcf 100644 --- a/README.md +++ b/README.md @@ -40,15 +40,20 @@ The portable regression in `tests/native_ca_packets.py` compiles the production against client API doubles. It covers initial state, listener timing, subsequent updates, new preset slots and missing local units. It does not launch the game or replace the 32-bit Windows DLL build. -`tests/native_callback_order.py` exercises the production cast and aura-removal registrars and detours -against the original DLL's measured linked-list traversal, including registration permutations and vetoes. +`tests/native_callback_order.py` exercises the production cast, aura-removal and aura-application +registrars and detours, and the effect-filter and visual-hide registrars and consumer, against the +original DLL's measured linked-list traversal, including registration permutations and vetoes. The original uses MSVC `unordered_set` callbacks: cast checks run aura requirements, stack requirements, -then class requirements; aura removal runs `AscAura137` before `AscSpellMods`. These two snapshots include -all corresponding callbacks in this source. Their native hash-bucket relationships remain identical -at every allocation-aligned 32-bit image base, so relocating the DLL preserves these orders. -Aura application, effect filtering and visual hiding still -use call-site ordering: their startup snapshots do not yet cover every reconstructed callback. Future -unknown callbacks in the two measured lists follow the captured entries in call-site order. +then class requirements; aura removal runs `AscAura137` before `AscSpellMods`; aura application runs +`AscAura137`, `AscSpellVisuals`, `AscSpellMods`, `AscAppearance`, then `AscSpawnVisibility`; effect +filtering and visual hiding run `AscSpellVisuals` before `AscQuestSpellHide`. These snapshots include +all corresponding callbacks in this source; the two emulated lists' native hash-bucket relationships +remain identical at every allocation-aligned 32-bit image base, so relocating the DLL preserves these +orders. +All five captured lists insert by the measured order (`InsertByCapturedOrder`): call-site ordering did +not reproduce them because registration execution order does not follow the call sites. The +aura-application list also carries one original-only callback that is not reconstructed - it runs after +the five captured entries, matching the rule that unknown callbacks follow the captured entries. ## Building diff --git a/docs/CALLBACK-ORDER-MEASURED.md b/docs/CALLBACK-ORDER-MEASURED.md new file mode 100644 index 0000000..6bcd6b1 --- /dev/null +++ b/docs/CALLBACK-ORDER-MEASURED.md @@ -0,0 +1,60 @@ +# Measured callback-list order — aura application, effect filtering, visual hiding + +The three lists that used to insert by call-site order were measured from the original's live list +images. Call-site order cannot reproduce them: registration execution order does not follow the call +sites — and one original-only callback on the aura-application list has no call site at all. + +## Captured order (call order of the original's detours and consumers) + +| list (native object) | order | +| --- | --- | +| `0x10BE2B6C` aura application (before `0x724820`) | `AscAura137::BeforeApply`, `AscSpellVisuals::BeforeAuraApply`, `AscSpellMods::AuraFlagOn`, `AscAppearance::BeforeAuraApply`, `AscSpawnVisibility::BeforeAuraApply`, *+ one original-only callback (below)* | +| `0x10BE2D78` effect filtering (`0x744AC0` replacement) | `AscSpellVisuals::RemoveEffect`, `AscQuestSpellHide::RemoveEffect` | +| `0x10BE2D98` visual hiding | `AscSpellVisuals::HideVisual`, `AscQuestSpellHide::HideVisual` | + +Sites (reference image) are in `tests/fixtures/original_callback_order.json`; the registrars rank by them. +The two filter lists are exactly reversed vs ascending site order. The aura list is not any site sort: +`AscSpellMods` registered before `AscSpellVisuals`, and `AscSpawnVisibility` before `AscAppearance`, +although their sites are the other way round. + +## The original-only sixth callback (aura application) + +- Runs **last of six** (`AscSpawnVisibility`'s entry precedes it). The list count for `0x10BE2B6C` is 6. +- Registered through the original's runtime layer: no static registration site exists anywhere in the + image; its address is only materialized at runtime (a VM lane cell, `.vm_sec` + `0xD70088`). It is not + reconstructed; `InsertByCapturedOrder`'s *unknown callbacks follow the captured entries* rule + reproduces its position. +- Behaviour — a spell veto called as `(unit, a, rec)`; `false` suppresses the aura application: + +```c +bool AuraVeto(void* unit, uint32_t, uint32_t rec) +{ + if (*(uint32_t*)((char*)unit + 0x14) != 4) return true; // interposes only on players + uint32_t spell = *(uint32_t*)rec; + uint32_t flags = *(uint32_t*)(*(uint32_t*)((char*)unit + 8) + 0x258); // PLAYER_FLAGS + if (spell == 0x79BB0 || spell == 0x79C3F) + return unit != ActivePlayer() && (flags & 0x8000) == 0 + && ((flags & 8) != 0 || AccountInfo()[0x0C] != 0) && AccountInfo()[0x0E] == 0; + if (spell == 0x79BC5) + return (flags & 0x8000) == 0; + if (spell == 0x79C40 || spell == 0x978960) + return unit != ActivePlayer() && (flags & 0x8000) == 0 + && ((flags & 8) != 0 || AccountInfo()[0x0D] != 0) && AccountInfo()[0x0E] == 0; + return true; +} +``` + +(Flag `8` = GM, `0x8000` = developer; `AccountInfo()` is the TLS account object accessor — +our `FUN_1008c870` class. All five spell ids are Ascension custom spells, absent from the shipped +`Spell.dbc`.) + +## Measurement and validation + +- Live walk of the original's list links in two independent game sessions of a sibling build + (`md5 8b3dd390…`), identical results; direction follows the hooks: `node = *(head)` (the list + head pointer), callback at `node+8`, next at `node+0`, stop when back at `head`. +- The same walk reproduces the two emulated fixtures (`Before71E930`, `After80B5D0`) byte-exactly in this + reference image; addresses were translated with per-band deltas validated on all entries. +- `tests/native_callback_order.py` locks the orders in: the registrars run under all registration + permutations, with vetoes, duplicate registrations, an off-array ("unknown") site, and — for visual + hiding — the production consumer's first-true-wins short-circuit. diff --git a/src/Ascension/AscRuntime.cpp b/src/Ascension/AscRuntime.cpp index b2fdb72..0468363 100644 --- a/src/Ascension/AscRuntime.cpp +++ b/src/Ascension/AscRuntime.cpp @@ -771,7 +771,15 @@ void OnBeforeTargetChange(Callback cb) { BeforeTargetList().push_back(cb); } void OnAfter52A980(Callback cb) { After52A980List().push_back(cb); } void OnAfterTargetChange(Callback cb) { AfterTargetList().push_back(cb); } void OnBefore6ECF80(void (*cb)(uint32_t)) { Before6ECF80List().push_back(cb); } -void OnBefore724820(UnitSpellVeto cb, uint32_t site) { InsertBySite(Before724820List(), cb, site); } +void OnBefore724820(UnitSpellVeto cb, uint32_t site) +{ + // Closed native list 0x10BE2B6C: AscAura137::BeforeApply, AscSpellVisuals::BeforeAuraApply, + // AscSpellMods::AuraFlagOn, AscAppearance::BeforeAuraApply, AscSpawnVisibility::BeforeAuraApply; + // an original-only sixth callback that is not reconstructed runs last. Registration execution + // order differs from the call-site order, so the captured order is authoritative. + static const uint32_t order[] = {0x10A683D0, 0x10323151, 0x10324D9A, 0x100C5B88, 0x101A2025}; + InsertByCapturedOrder(Before724820List(), cb, site, order); +} void OnBefore71E930(UnitSpellVeto cb, uint32_t site) { // Closed native list 0x10BE2B8C: AscAura137::BeforeRemove, AscSpellMods::AuraFlagOff. @@ -784,8 +792,18 @@ void OnAfter80B5D0(CastVeto cb, uint32_t site) static const uint32_t order[] = {0x10324DB8, 0x10324DAE, 0x10171F65}; InsertByCapturedOrder(After80B5D0List(), cb, site, order); } -void OnEffectFilter(EffectFilter cb, uint32_t site) { InsertBySite(EffectFilterList(), cb, site); } -void OnVisualHide(VisualHide cb, uint32_t site) { InsertBySite(VisualHideList(), cb, site); } +void OnEffectFilter(EffectFilter cb, uint32_t site) +{ + // Closed native list 0x10BE2D78: AscSpellVisuals::RemoveEffect, AscQuestSpellHide::RemoveEffect. + static const uint32_t order[] = {0x1032313D, 0x10112625}; + InsertByCapturedOrder(EffectFilterList(), cb, site, order); +} +void OnVisualHide(VisualHide cb, uint32_t site) +{ + // Closed native list 0x10BE2D98: AscSpellVisuals::HideVisual, AscQuestSpellHide::HideVisual. + static const uint32_t order[] = {0x10323147, 0x1011262F}; + InsertByCapturedOrder(VisualHideList(), cb, site, order); +} bool VisualHidden(uint32_t kind, uint32_t guidLo, uint32_t guidHi, uint32_t spell) { for (auto& cb : VisualHideList()) diff --git a/tests/fixtures/original_callback_order.json b/tests/fixtures/original_callback_order.json index 61da870..76cba5e 100644 --- a/tests/fixtures/original_callback_order.json +++ b/tests/fixtures/original_callback_order.json @@ -4,8 +4,8 @@ "hash_helper": "0x100996e0", "hash": "FNV-1a over four little-endian relocated callback-pointer bytes", "bucket_mask": 7, - "method": "Offline x86 startup emulation; follow head->next nodes to head, reading callback at node+8; closed list count verified.", - "limits": "Observed startup prefix only; later original-only registrations and other lists are unmeasured.", + "method": "Offline x86 startup emulation for Before71E930 / After80B5D0: follow head->next nodes to head, reading callback at node+8; closed list count verified. Before724820 / EffectFilter / VisualHide additionally measured from live list traversal in two independent game sessions of a sibling build; addresses translated with per-band deltas validated against both emulated lists.", + "limits": "Before71E930 and After80B5D0 are complete for this image. Before724820 carries one original-only callback (runtime-registered, no static call site, not reconstructed) that runs after the five captured entries. Remaining lists are unmeasured.", "Before71E930": { "object": "0x10be2b8c", "callbacks": [ @@ -20,5 +20,31 @@ {"name": "AscSpellMods::StackRequirement", "function": "0x103267e0", "site": "0x10324dae"}, {"name": "AscCAMgr::CastRequirements", "function": "0x1016fde0", "site": "0x10171f65"} ] + }, + "Before724820": { + "object": "0x10be2b6c", + "callbacks": [ + {"name": "AscAura137::BeforeApply", "function": "0x10a4e2e0", "site": "0x10a683d0"}, + {"name": "AscSpellVisuals::BeforeAuraApply", "function": "0x10323300", "site": "0x10323151"}, + {"name": "AscSpellMods::AuraFlagOn", "function": "0x10325aa0", "site": "0x10324d9a"}, + {"name": "AscAppearance::BeforeAuraApply", "function": "0x100c6410", "site": "0x100c5b88"}, + {"name": "AscSpawnVisibility::BeforeAuraApply", "function": "0x101a1fa0", "site": "0x101a2025"}, + {"name": "(original-only, not reconstructed)", "function": null, "site": null, + "note": "Called last. Registered by the original's runtime layer, no static registration site."} + ] + }, + "EffectFilter": { + "object": "0x10be2d78", + "callbacks": [ + {"name": "AscSpellVisuals::RemoveEffect", "function": "0x10323480", "site": "0x1032313d"}, + {"name": "AscQuestSpellHide::RemoveEffect", "function": "0x10112650", "site": "0x10112625"} + ] + }, + "VisualHide": { + "object": "0x10be2d98", + "callbacks": [ + {"name": "AscSpellVisuals::HideVisual", "function": "0x10323410", "site": "0x10323147"}, + {"name": "AscQuestSpellHide::HideVisual", "function": "0x10112640", "site": "0x1011262f"} + ] } } diff --git a/tests/native_callback_order.py b/tests/native_callback_order.py index b570c7d..3cc58e2 100644 --- a/tests/native_callback_order.py +++ b/tests/native_callback_order.py @@ -22,13 +22,24 @@ { using UnitSpellVeto = bool (*)(void*, uint32_t, uint32_t); using CastVeto = bool (*)(uint32_t, uint32_t, uint32_t, uint32_t, uint32_t); +using EffectFilter = bool (*)(void*, void*); +using VisualHide = bool (*)(uint32_t, uint32_t, uint32_t, uint32_t); +std::vector>& Before724820List() +{ static std::vector> v; return v; } std::vector>& Before71E930List() { static std::vector> v; return v; } std::vector>& After80B5D0List() { static std::vector> v; return v; } +std::vector>& EffectFilterList() +{ static std::vector> v; return v; } +std::vector>& VisualHideList() +{ static std::vector> v; return v; } @INSERT@ @REMOVE_REGISTER@ @CAST_REGISTER@ +@APPLY_REGISTER@ +@EFFECT_REGISTER@ +@VISUAL_REGISTER@ std::vector trace; int veto = -1; bool clientCast = true; @@ -47,13 +58,29 @@ trace.push_back(N); return veto != N; } +template bool Effect(void* unit, void* effect) +{ + Check(unit == reinterpret_cast(123) && effect == reinterpret_cast(456)); + trace.push_back(N); + return false; +} +template bool Hide(uint32_t kind, uint32_t guidLo, uint32_t guidHi, uint32_t spell) +{ + Check(kind == 1 && guidLo == 2 && guidHi == 3 && spell == 4); + trace.push_back(N); + return N == 1; +} int OriginalRemove(void*, void*, uint32_t, uint32_t) { trace.push_back(9); return 7; } +int OriginalApply(void*, void*, uint32_t, uint32_t) { trace.push_back(9); return 7; } int OriginalCast(uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { trace.push_back(9); return clientCast; } auto g_71E930Trampoline = &OriginalRemove; +auto g_724820Trampoline = &OriginalApply; auto g_80B5D0Trampoline = &OriginalCast; @REMOVE_DETOUR@ @CAST_DETOUR@ +@APPLY_DETOUR@ +@VISUAL_CONSUMER@ } int main() { @@ -98,8 +125,54 @@ OnAfter80B5D0(cast[0], castSites[0]); Check(After80B5D0List().size() == 3); } while (std::next_permutation(castOrder.begin(), castOrder.end())); + const uint32_t auraApplySites[] = {@AURA_APPLY_SITES@}; + const uint32_t effectSites[] = {@EFFECT_SITES@}; + const uint32_t visualSites[] = {@VISUAL_SITES@}; + UnitSpellVeto auraApply[] = {&Aura<0>, &Aura<1>, &Aura<2>, &Aura<3>, &Aura<4>}; + std::array auraApplyOrder{0, 1, 2, 3, 4}; + do + { + Before724820List().clear(); + for (int i : auraApplyOrder) OnBefore724820(auraApply[i], auraApplySites[i]); + for (veto = -1; veto < 5; ++veto) + { + trace.clear(); + int result = Detour724820(reinterpret_cast(123), nullptr, 4, 5); + std::vector expected; + for (int i = 0; i < (veto < 0 ? 5 : veto + 1); ++i) expected.push_back(i); + if (veto < 0) expected.push_back(9); + Check(trace == expected && result == (veto < 0 ? 7 : 0)); + } + OnBefore724820(auraApply[0], auraApplySites[0]); + Check(Before724820List().size() == 5); + } while (std::next_permutation(auraApplyOrder.begin(), auraApplyOrder.end())); + Before724820List().clear(); + OnBefore724820(&Aura<7>, 0xDEADBEEF); + for (int i = 0; i < 5; ++i) OnBefore724820(auraApply[i], auraApplySites[i]); + veto = -1; + trace.clear(); + Check(Detour724820(reinterpret_cast(123), nullptr, 4, 5) == 7 + && trace == std::vector{0, 1, 2, 3, 4, 7, 9}); + EffectFilter effectFilter[] = {&Effect<0>, &Effect<1>}; + VisualHide visualHide[] = {&Hide<0>, &Hide<1>}; + std::array filterOrder{0, 1}; + do + { + EffectFilterList().clear(); + for (int i : filterOrder) OnEffectFilter(effectFilter[i], effectSites[i]); + Check(EffectFilterList().size() == 2 && EffectFilterList()[0].second == &Effect<0> + && EffectFilterList()[1].second == &Effect<1>); + OnEffectFilter(effectFilter[0], effectSites[0]); + Check(EffectFilterList().size() == 2); + VisualHideList().clear(); + for (int i : filterOrder) OnVisualHide(visualHide[i], visualSites[i]); + trace.clear(); + Check(VisualHidden(1, 2, 3, 4) && trace == std::vector{0, 1}); + OnVisualHide(visualHide[1], visualSites[1]); + Check(VisualHideList().size() == 2); + } while (std::next_permutation(filterOrder.begin(), filterOrder.end())); std::cout << checks << " checks, " << failures << " failures: original callback order, vetoes, " - << "client fall-through and duplicate registration\n"; + << "client fall-through, duplicate and unknown registration\n"; return failures ? 1 : 0; } ''' @@ -120,6 +193,16 @@ def bucket(address, base): print('PASS: native bucket relationships survive all 65,536 allocation-aligned 32-bit image bases', flush=True) +def check_captured_lists(fixture): + for name in ('Before71E930', 'After80B5D0', 'Before724820', 'EffectFilter', 'VisualHide'): + sites = [e['site'] for e in fixture[name]['callbacks'] if e.get('site')] + functions = [e['function'] for e in fixture[name]['callbacks'] if e.get('function')] + assert len(sites) == len(set(sites)) and len(functions) == len(set(functions)), name + original_only = [e for e in fixture['Before724820']['callbacks'] if not e.get('site')] + assert len(original_only) == 1 and original_only[0]['function'] is None, 'original-only entry' + print('PASS: captured lists have distinct callback functions and sites', flush=True) + + def main(): parser = argparse.ArgumentParser(description='Compare production registrars and detours with native DLL order.') parser.add_argument('--source-ref') @@ -129,17 +212,26 @@ def main(): if args.source_ref else (ROOT / name).read_text(encoding='utf-8') fixture = json.loads((ROOT / 'tests/fixtures/original_callback_order.json').read_text(encoding='utf-8')) check_relocation_buckets(fixture) + check_captured_lists(fixture) harness = HARNESS for marker, signature in [ ('INSERT', 'template void InsertBySite('), ('REMOVE_REGISTER', 'void OnBefore71E930('), ('CAST_REGISTER', 'void OnAfter80B5D0('), + ('APPLY_REGISTER', 'void OnBefore724820('), + ('EFFECT_REGISTER', 'void OnEffectFilter('), + ('VISUAL_REGISTER', 'void OnVisualHide('), ('REMOVE_DETOUR', 'int __fastcall Detour71E930('), ('CAST_DETOUR', 'int __cdecl Detour80B5D0('), + ('APPLY_DETOUR', 'int __fastcall Detour724820('), + ('VISUAL_CONSUMER', 'bool VisualHidden('), ]: harness = harness.replace(f'@{marker}@', function(source, signature)) - for marker, name in [('AURA_SITES', 'Before71E930'), ('CAST_SITES', 'After80B5D0')]: - harness = harness.replace(f'@{marker}@', ', '.join(e['site'] for e in fixture[name]['callbacks'])) + for marker, name in [('AURA_SITES', 'Before71E930'), ('CAST_SITES', 'After80B5D0'), + ('AURA_APPLY_SITES', 'Before724820'), ('EFFECT_SITES', 'EffectFilter'), + ('VISUAL_SITES', 'VisualHide')]: + sites = [e['site'] for e in fixture[name]['callbacks'] if e.get('site')] + harness = harness.replace(f'@{marker}@', ', '.join(sites)) compiler = shutil.which(os.environ.get('CXX', 'cl.exe' if os.name == 'nt' else 'c++')) if not compiler: raise SystemExit('A C++20 compiler is required.') From 54bf50c390641eaa282258daa5762276caf96634 Mon Sep 17 00:00:00 2001 From: CoinThrow <108105682+CoinThrow@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:35:19 +0200 Subject: [PATCH 2/3] docs: identify the sixth aura-application callback (Mentor / Newcomer / Returning Player / overhead icon) --- docs/CALLBACK-ORDER-MEASURED.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/CALLBACK-ORDER-MEASURED.md b/docs/CALLBACK-ORDER-MEASURED.md index 6bcd6b1..b808ebb 100644 --- a/docs/CALLBACK-ORDER-MEASURED.md +++ b/docs/CALLBACK-ORDER-MEASURED.md @@ -24,7 +24,8 @@ although their sites are the other way round. image; its address is only materialized at runtime (a VM lane cell, `.vm_sec` + `0xD70088`). It is not reconstructed; `InsertByCapturedOrder`'s *unknown callbacks follow the captured entries* rule reproduces its position. -- Behaviour — a spell veto called as `(unit, a, rec)`; `false` suppresses the aura application: +- Behaviour — a spell veto called as `(unit, a, rec)`; `false` suppresses the aura application + (the detour returns without calling the original `0x724820`): ```c bool AuraVeto(void* unit, uint32_t, uint32_t rec) @@ -48,6 +49,25 @@ bool AuraVeto(void* unit, uint32_t, uint32_t rec) our `FUN_1008c870` class. All five spell ids are Ascension custom spells, absent from the shipped `Spell.dbc`.) +### Spell identification — the client's Mentor / Newcomer / Returning-Player / overhead-icon handling + +The five ids are CoA's community and staff marker spells (community-archive identification, not server +data — AscensionDB preservation archive, "Jeff-Fro CoA in-game spell tooltip capture", 2026-09-02, +snapshot `2aa98857`): + +| id | name | captured tooltip | +| --- | --- | --- | +| `0x79BB0` (498608) | **Mentor** | `Mentor` | +| `0x79C3F` (498751) | **Mentor** | `Mentor \| I am a Mentor and I am here to help! New players can ask me questions about the game!` | +| `0x79BC5` (498629) | **Overhead Icon - Ascension Logo** | `Overhead Icon - Ascension Logo \| Instant` | +| `0x79C40` (498752) | **New Player Help - Newcomer** | `New Player Help - Newcomer \| Instant` | +| `0x978960` (9931104) | **Returning Player** | — | + +The condition split mirrors the names: the two Mentor spells share the account byte `0x0C` branch, the +Newcomer / Returning-Player pair shares the `0x0D` branch, and the Ascension-logo overhead icon has its +own rule. Note the GM flag never suppresses here — it is only ever an *allow* path; the one suppressing +flag is `0x8000` (developer), and the tag pairs additionally never apply to your own active player. + ## Measurement and validation - Live walk of the original's list links in two independent game sessions of a sibling build From 6cf7e6e9f4421c7d9549a8091e9905a2c53b97ff Mon Sep 17 00:00:00 2001 From: CoinThrow <108105682+CoinThrow@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:38:56 +0200 Subject: [PATCH 3/3] feat(AscNativeLogon): native reconstruction of Ascension's own logon protocol (build-gated) AscNativeLogon.cpp/.hpp installs the original DLL's three logon hooks (0x8CCE00, 0x9A83E0, 0x9A88C0) and the four handshake length gates (0x14 -> 0x20), replacing stock SRP6 with X25519 + ChaCha20 + HMAC-SHA256. Compiled only with -DASCENSION_NATIVE_LOGON=ON; the default stays SRP6-restore. tools/ascension-auth-keygen.ps1: X25519 key pair tool (RFC 7748 self-test plus the project's live-test pair). README/docs: native-logon build section, the 2026-10-10 live-validation record (proof check PASSED -> COP_AUTHENTICATE AUTH_OK -> COP_GET_CHARACTERS result=TRUE) and the server requirement found during it (authserver must persist key_session as account.session_key for the world handoff). --- CMakeLists.txt | 9 + README.md | 39 +- WotLKExtensions/cmake/PatchConfig.hpp.in | 4 + docs/DLL_REFERENCE.intro.md | 2 +- docs/DLL_REFERENCE.md | 2 +- src/Ascension/AscMain.cpp | 22 +- src/Ascension/AscNativeLogon.cpp | 1092 ++++++++++++++++++++++ src/Ascension/AscNativeLogon.hpp | 21 + tools/ascension-auth-keygen.ps1 | 204 ++++ 9 files changed, 1385 insertions(+), 10 deletions(-) create mode 100644 src/Ascension/AscNativeLogon.cpp create mode 100644 src/Ascension/AscNativeLogon.hpp create mode 100644 tools/ascension-auth-keygen.ps1 diff --git a/CMakeLists.txt b/CMakeLists.txt index 44c7f77..4bf03cf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -33,6 +33,15 @@ option(ACTIONBAR_LUA "" OFF) option(DEVHELPER_LUA "" OFF) option(GLUEMGREXTENSION "Glue-stage Lua function registration" ON) +# --- Ascension native logon (our reconstruction; not upstream) ------------------------------------ +# Installs the original DLL's three logon hooks (client hello builder 0x8CCE00, key-material copy +# 0x9A83E0, 20-byte proof check 0x9A88C0) plus the four exe length-gate patches, replacing the +# client's native SRP6 handshake with Ascension's X25519/ChaCha20/HMAC-SHA256 scheme. OFF by +# default: only a server that speaks the same protocol (the collaborator's fork) can answer it. +# See src/Ascension/AscNativeLogon.cpp for the protocol, provenance and self-test. +option(ASCENSION_NATIVE_LOGON "Install the reconstructed Ascension native logon hooks (server must speak the protocol)" OFF) +set(ASCENSION_NATIVE_LOGON_SERVER_KEY "1a03d4c71f289f39046fad519f8af3e92b793709a8a340e492b4ff5ff55e1537" CACHE STRING "Server X25519 public key (hex, 64 chars) used by ASCENSION_NATIVE_LOGON") + set(UP ${CMAKE_CURRENT_SOURCE_DIR}/WotLKExtensions) configure_file(${UP}/cmake/PatchConfig.hpp.in ${CMAKE_BINARY_DIR}/gen/PatchConfig.hpp) diff --git a/README.md b/README.md index 852edcf..acf4cbc 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,11 @@ packets and fires events. ## Deliberate differences from the genuine DLL - **Logon protocol.** The genuine DLL hooks `0x8CCE00`, `0x9A83E0` and `0x9A88C0` to replace SRP6 with - Ascension's own logon protocol. These are **not installed**, so the client authenticates with stock SRP6 - against an ordinary AzerothCore authserver. See `src/Ascension/AscMain.cpp`. + Ascension's own logon protocol. **Not installed by default** -- the client authenticates with stock SRP6 + against an AzerothCore authserver. A native reconstruction of the protocol (X25519 / ChaCha20 / + HMAC-SHA256, plus the four handshake length gates) now ships in `src/Ascension/AscNativeLogon.cpp`, + compiled in only when the build enables `-DASCENSION_NATIVE_LOGON=ON` (see below); it exists so the + client can be tested against a server that speaks the original protocol. See `src/Ascension/AscMain.cpp`. - **Character advancement initialization.** The native `0x726` known-entries handler synchronizes the build's unit parameters before notifying listeners and cloning its pending build. Waiting for the next manager tick leaves a new build at class and level zero during the first native update. This is an @@ -67,6 +70,35 @@ cmake --build build The output is `build\Extensions.dll`. Place it next to `Ascension.exe`, which loads it itself (there is no injector). +### Optional: native logon build (test against an Ascension-protocol server) + +```bat +cmake -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DASCENSION_NATIVE_LOGON=ON -S . -B build\nl +cmake --build build\nl +``` + +This compiles in `src/Ascension/AscNativeLogon.cpp`: it installs the original DLL's three logon hooks +(`0x8CCE00`, `0x9A83E0`, `0x9A88C0`) plus the four handshake length gates, replacing the client's stock SRP6 +with Ascension's own handshake (X25519 + ChaCha20 + HMAC-SHA256). It only works against a server that +answers that protocol. The server's X25519 public key is a build setting, +`-DASCENSION_NATIVE_LOGON_SERVER_KEY=<64 hex chars>` (default: the key live-tested during the 2026-10 +campaign; the server prints its key at startup). Run the client with `-extlog 1` and check +`Logs\Extensions.log` for the `native-logon:` lines. + +Live-validated end to end on 2026-10-10 against a `feature/ascension-native-login` server +(`CoA.AscensionAuth = 1`, matching key pair): the client logged `native-logon: proof check PASSED +(HMAC-SHA256(key_derived, "OK"))`, the server answered `Ascension custom auth: proof replied, session +authorised`, and the client went on through `COP_AUTHENTICATE ... AUTH_OK` to the character list +(`COP_GET_CHARACTERS ... result=TRUE`). No client-side patches were involved: this build installs its own +trampolines and length gates. + +One server requirement found during that test: after the custom proof the authserver must persist the +handshake's 40-byte `key_session` as `account.session_key`. The worldserver validates the client's +`CMSG_AUTH_SESSION` digest and initialises its header cipher from that value - without it every world +connection fails its digest check immediately after the auth completes. The partner fork +(`feature/ascension-native-login`) handles this in `AuthSession.cpp` (`HandleLogonProof`, Ascension +client branch). + The DLL's own developer log (`Logs\Extensions.log`) is off by default; start the client with `-extlog 1` to turn it on. @@ -89,6 +121,9 @@ at "Authenticating" and then disconnects before character select. `CoA.Enable = 1` is also required. For clients that do not connect from loopback, set `CoA.AllowRemoteClients = 1` too, because both settings apply only to loopback otherwise. +The authserver side of the custom handshake additionally requires the `key_session` persistence described +in the native-logon section above (`AuthSession.cpp`, Ascension client branch). + ## Client: required changes to `patch-B.MPQ` The stock Ascension client's `Data\patch-B.MPQ` is built for the Ascension Live servers. To connect to any diff --git a/WotLKExtensions/cmake/PatchConfig.hpp.in b/WotLKExtensions/cmake/PatchConfig.hpp.in index f0a3ecb..ef79758 100644 --- a/WotLKExtensions/cmake/PatchConfig.hpp.in +++ b/WotLKExtensions/cmake/PatchConfig.hpp.in @@ -27,3 +27,7 @@ #cmakedefine01 ACTIONBAR_LUA #cmakedefine01 DEVHELPER_LUA #cmakedefine01 GLUEMGREXTENSION + +// Ascension native logon (see src/Ascension/AscNativeLogon.cpp) +#cmakedefine01 ASCENSION_NATIVE_LOGON +#cmakedefine ASCENSION_NATIVE_LOGON_SERVER_KEY "@ASCENSION_NATIVE_LOGON_SERVER_KEY@" diff --git a/docs/DLL_REFERENCE.intro.md b/docs/DLL_REFERENCE.intro.md index 4e1ff41..6627cfb 100644 --- a/docs/DLL_REFERENCE.intro.md +++ b/docs/DLL_REFERENCE.intro.md @@ -53,7 +53,7 @@ who does want the original can go straight to it in the decompile. ## Deliberate differences from the genuine DLL - **Logon protocol.** The genuine DLL hooks `0x8CCE00`, `0x9A83E0` and `0x9A88C0` to replace SRP6 with Ascension's - own logon protocol. They are not installed, so the client authenticates with stock SRP6 against an AzerothCore + own logon protocol. They are not installed by default, so the client authenticates with stock SRP6 against an AzerothCore authserver (see `AscMain.cpp`, and `CLAUDE.md` G0-02). - **Anti-cheat.** It is loaded exactly as the original does (`DivxTac.dll` after world entry, plus the account kill switch). Nothing is added or removed. diff --git a/docs/DLL_REFERENCE.md b/docs/DLL_REFERENCE.md index 2134adc..4e85912 100644 --- a/docs/DLL_REFERENCE.md +++ b/docs/DLL_REFERENCE.md @@ -57,7 +57,7 @@ who does want the original can go straight to it in the decompile. ## Deliberate differences from the genuine DLL - **Logon protocol.** The genuine DLL hooks `0x8CCE00`, `0x9A83E0` and `0x9A88C0` to replace SRP6 with Ascension's - own logon protocol. They are not installed, so the client authenticates with stock SRP6 against an AzerothCore + own logon protocol. They are not installed by default, so the client authenticates with stock SRP6 against an AzerothCore authserver (see `AscMain.cpp`, and `CLAUDE.md` G0-02). - **Anti-cheat.** It is loaded exactly as the original does (`DivxTac.dll` after world entry, plus the account kill switch). Nothing is added or removed. diff --git a/src/Ascension/AscMain.cpp b/src/Ascension/AscMain.cpp index 994079c..8e0be05 100644 --- a/src/Ascension/AscMain.cpp +++ b/src/Ascension/AscMain.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -27,14 +28,17 @@ extern "C" __declspec(dllexport) int ClientExtensionsDummy() { return 1; } // makes): the upstream WotLK-Extensions feature patches WoWTime (+ the year-offset setting only it reads), // CMap, Misc, CGPlayer (char-create race fix, combo-point fix, LFD class roles), CGTooltip, CMissile, // MacroConditions, plus Spell / LuaUnlock (already no-ops here). -// Deliberately NOT installed (the one remaining hook gap vs the original, 2026-09-27 live audit): the three -// hooks of Ascension's own logon protocol -- +// Native logon is now reconstructed (src/Ascension/AscNativeLogon.cpp): the three hooks of Ascension's +// own logon protocol -- // 0x8CCE00 -> FUN_100e3dc0 (into the encrypted/VM region), 0x9A83E0 -> FUN_100e5d70 (copies the session key // material into the client's auth object), 0x9A88C0 -> FUN_100e5dd0 (the 20-byte server proof, -// HMAC(K, "OK")). -// Installing them replaces the client's native SRP6 with Ascension's logon, which our AzerothCore authserver -// cannot answer until the translation proxy (BUILD_PLAN P3) exists. The JS client re-routes exactly these three -// for the same reason. Project decision: SRP6-restore (CLAUDE.md G0-02). +// HMAC(K, "OK")) +// -- plus the four exe length-gate patches, are installed behind the ASCENSION_NATIVE_LOGON build option +// (default OFF). With it OFF this DLL keeps the original audit behaviour: the client's native SRP6 stays +// in place, matching our AzerothCore authserver until the translation proxy (BUILD_PLAN P3) exists. +// With it ON the client speaks Ascension's protocol (X25519 / ChaCha20 / HMAC-SHA256) and needs a server +// that answers it (the collaborator's fork does). Project decision G0-02 (SRP6-restore) is unaffected: +// the option defaults to OFF. static void ApplyUpstreamPatches() { #if CUSTOMPACKETS_PATCH @@ -135,6 +139,12 @@ BOOL WINAPI DllMain(HINSTANCE hinst, DWORD reason, LPVOID) { ApplyUpstreamPatches(); AscLog::Printf("upstream patches applied"); +#if ASCENSION_NATIVE_LOGON + // Ascension's own logon protocol, reconstructed natively (see AscNativeLogon.hpp). + // Switches the client from stock SRP6 to the original's X25519/ChaCha20/HMAC + // handshake; only meaningful against a server that speaks it. + AscNativeLogon::Install(); +#endif // The original DLL's module runtime: its custom event names, its world-entry / glue-screen // lifecycle hooks, then each transcribed subsystem's init (which registers into both). AscRuntime::RegisterEventNames(); diff --git a/src/Ascension/AscNativeLogon.cpp b/src/Ascension/AscNativeLogon.cpp new file mode 100644 index 0000000..d1e5869 --- /dev/null +++ b/src/Ascension/AscNativeLogon.cpp @@ -0,0 +1,1092 @@ +// Ascension's own logon protocol - native reconstruction (see AscNativeLogon.hpp). +// +// WHAT THIS INSTALLS (all byte-checked against the deployed client, Ascension.exe D4CA3DD1): +// +// * 0x8CCE00 hello serializer. The exe builds the login packet (appends [00][08], then calls +// this) and sends whatever the serializer appended. The reconstruction appends the +// custom record the campaign captured and verified live: [size u16][FC F4 F4 E6] +// [tag 16][masked payload][trailer 4]. The masked payload is the client hello the +// server parses (login, X25519 public key, nonce, password tag, ChaCha20-encrypted +// password); see BuildHelloRecord. +// * 0x9A83E0 key material. In stock SRP6 this computes K from the challenge; when the custom +// handshake is in play it must instead copy the 40-byte session key (key_session, +// the campaign's ks40) into the auth object at ecx+0x20 (the exe passes +// auth-context+0xA8 as ecx, so the key lands at auth-context+0xC8 - exactly where +// the reply handler later hands it to the connection). +// * 0x9A88C0 server-proof check. Stock: compare the 20-byte SRP6 M2. Custom: verify the 32-byte +// HMAC-SHA256(key_derived, "OK") the server replies with. Nonzero return = fail. +// * Four length gates 0x14 -> 0x20 (0x8CC7FA, 0x8CC856, 0xDFC024, 0xDFC034) so the reply handler +// reads and passes a 32-byte proof field. With 0x14 the check structurally fails. +// +// CRYPTO (all derived from the campaign's verified references - re/ascens_crypto.py, +// re/fake_auth8_pure.py, re/ascension_auth_probe.py, and the server's AscensionAuth.h): +// secret = 32 random bytes; client_public = X25519(secret, base) +// shared = X25519(secret, server_public) server_public = the KC1 slot value +// key_derived = derive(KC3, shared, IN4, 32) (HMAC-SHA256-based, Ascension's KDF) +// key_session = derive(KC3, shared, IN5, 40) +// proof = HMAC-SHA256(key_derived, "OK") +// hello payload encrypted with ChaCha20(KC2, NC2, counter 1) XOR 0xED over all but the last 4 +// bytes; the password with ChaCha20(key_derived, nonce, counter 1). +// The 'server_public' is a build setting: ASCENSION_NATIVE_LOGON_SERVER_KEY (64 hex chars - the +// value the server prints at startup; the default is the campaign's live-tested key). +// +// DELIBERATE LIMITS (a test/translation build, not a match for the original's obfuscated code): +// - The record's constant fields (platform, locale, timezone bias, IP) are the values the +// verified reference sent; the server does not read them. +// - The three fingerprint/tag fields are fresh random bytes per connection (the reference +// implementations and the server treat them as opaque). +// - The stock fallbacks (SRP6 key computation, M2 check) are not kept: this is installed only +// for servers that speak the custom handshake, and the genuine DLL replaces the same sites. + +#if __has_include() +#include +#endif +#ifndef ASCENSION_NATIVE_LOGON +#define ASCENSION_NATIVE_LOGON 0 +#endif + +#include + +#if ASCENSION_NATIVE_LOGON + +#define _CRT_RAND_S +#include + +#include +#include +#include +#include +#include + +#ifndef ASCENSION_NATIVE_LOGON_SERVER_KEY +// The campaign's live-tested server public key (the pair priv 22dfa316... / pub 1a03d4c7...). +#define ASCENSION_NATIVE_LOGON_SERVER_KEY "1a03d4c71f289f39046fad519f8af3e92b793709a8a340e492b4ff5ff55e1537" +#endif + +namespace +{ + // --------------------------------------------------------------------------------------------- + // Cryptographic primitives (self-contained; MSVC x86, no external dependencies). + // --------------------------------------------------------------------------------------------- + + inline uint32_t LoadBe32(const uint8_t* p) + { + return (uint32_t(p[0]) << 24) | (uint32_t(p[1]) << 16) | (uint32_t(p[2]) << 8) | uint32_t(p[3]); + } + + inline void StoreBe32(uint8_t* p, uint32_t v) + { + p[0] = uint8_t(v >> 24); p[1] = uint8_t(v >> 16); p[2] = uint8_t(v >> 8); p[3] = uint8_t(v); + } + + inline uint32_t LoadLe32(const uint8_t* p) + { + return uint32_t(p[0]) | (uint32_t(p[1]) << 8) | (uint32_t(p[2]) << 16) | (uint32_t(p[3]) << 24); + } + + inline void StoreLe32(uint8_t* p, uint32_t v) + { + p[0] = uint8_t(v); p[1] = uint8_t(v >> 8); p[2] = uint8_t(v >> 16); p[3] = uint8_t(v >> 24); + } + + // ---- SHA-256 ---- + + inline uint32_t Rotr32(uint32_t x, int n) { return (x >> n) | (x << (32 - n)); } + + struct Sha256Context + { + uint32_t state[8]; + uint64_t bitLength; + uint8_t buffer[64]; + size_t buffered; + + void Init() + { + static const uint32_t iv[8] = + { + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 + }; + memcpy(state, iv, sizeof(state)); + bitLength = 0; + buffered = 0; + } + + static void Compress(uint32_t state[8], const uint8_t block[64]) + { + static const uint32_t k[64] = + { + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 + }; + + uint32_t w[64]; + for (int i = 0; i < 16; ++i) + w[i] = LoadBe32(block + i * 4); + for (int i = 16; i < 64; ++i) + { + uint32_t s0 = Rotr32(w[i - 15], 7) ^ Rotr32(w[i - 15], 18) ^ (w[i - 15] >> 3); + uint32_t s1 = Rotr32(w[i - 2], 17) ^ Rotr32(w[i - 2], 19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + s0 + w[i - 7] + s1; + } + + uint32_t a = state[0], b = state[1], c = state[2], d = state[3]; + uint32_t e = state[4], f = state[5], g = state[6], h = state[7]; + for (int i = 0; i < 64; ++i) + { + uint32_t s1 = Rotr32(e, 6) ^ Rotr32(e, 11) ^ Rotr32(e, 25); + uint32_t ch = (e & f) ^ (~e & g); + uint32_t t1 = h + s1 + ch + k[i] + w[i]; + uint32_t s0 = Rotr32(a, 2) ^ Rotr32(a, 13) ^ Rotr32(a, 22); + uint32_t maj = (a & b) ^ (a & c) ^ (b & c); + uint32_t t2 = s0 + maj; + h = g; g = f; f = e; e = d + t1; + d = c; c = b; b = a; a = t1 + t2; + } + state[0] += a; state[1] += b; state[2] += c; state[3] += d; + state[4] += e; state[5] += f; state[6] += g; state[7] += h; + } + + void Update(const uint8_t* data, size_t length) + { + bitLength += uint64_t(length) * 8; + while (length > 0) + { + size_t take = 64 - buffered; + if (take > length) + take = length; + memcpy(buffer + buffered, data, take); + buffered += take; + data += take; + length -= take; + if (buffered == 64) + { + Compress(state, buffer); + buffered = 0; + } + } + } + + void Final(uint8_t out[32]) + { + uint64_t bits = bitLength; + uint8_t one = 0x80; + Update(&one, 1); + uint8_t zero = 0; + while (buffered != 56) + Update(&zero, 1); + uint8_t lengthBytes[8]; + for (int i = 0; i < 8; ++i) + lengthBytes[i] = uint8_t(bits >> (8 * (7 - i))); + Update(lengthBytes, 8); // note: bitLength field no longer mattering + for (int i = 0; i < 8; ++i) + StoreBe32(out + i * 4, state[i]); + } + }; + + void Sha256(uint8_t out[32], const uint8_t* data, size_t length) + { + Sha256Context ctx; + ctx.Init(); + ctx.Update(data, length); + ctx.Final(out); + } + + void HmacSha256(uint8_t out[32], const uint8_t* key, size_t keyLength, const uint8_t* message, size_t messageLength) + { + uint8_t block[64]; + memset(block, 0, sizeof(block)); + if (keyLength > sizeof(block)) + { + Sha256(block, key, keyLength); // long keys are hashed first + } + else + { + memcpy(block, key, keyLength); + } + + uint8_t pad[64]; + uint8_t inner[32]; + + for (int i = 0; i < 64; ++i) + pad[i] = block[i] ^ 0x36; + Sha256Context ctx; + ctx.Init(); + ctx.Update(pad, sizeof(pad)); + ctx.Update(message, messageLength); + ctx.Final(inner); + + for (int i = 0; i < 64; ++i) + pad[i] = block[i] ^ 0x5c; + ctx.Init(); + ctx.Update(pad, sizeof(pad)); + ctx.Update(inner, sizeof(inner)); + ctx.Final(out); + } + + // ---- ChaCha20 (IETF layout: 32-bit counter, 96-bit nonce, 20 rounds) ---- + + inline uint32_t Rotl32(uint32_t x, int n) { return (x << n) | (x >> (32 - n)); } + + inline void ChaChaQuarter(uint32_t* w, int a, int b, int c, int d) + { + w[a] += w[b]; w[d] = Rotl32(w[d] ^ w[a], 16); + w[c] += w[d]; w[b] = Rotl32(w[b] ^ w[c], 12); + w[a] += w[b]; w[d] = Rotl32(w[d] ^ w[a], 8); + w[c] += w[d]; w[b] = Rotl32(w[b] ^ w[c], 7); + } + + void ChaCha20Stream(uint8_t* out, size_t length, const uint8_t key[32], const uint8_t nonce[12], uint32_t counter) + { + uint32_t state[16]; + state[0] = 0x61707865; state[1] = 0x3320646e; state[2] = 0x79622d32; state[3] = 0x6b206574; + for (int i = 0; i < 8; ++i) + state[4 + i] = LoadLe32(key + i * 4); + for (int i = 0; i < 3; ++i) + state[13 + i] = LoadLe32(nonce + i * 4); + state[12] = counter; + + size_t written = 0; + while (written < length) + { + uint32_t w[16]; + memcpy(w, state, sizeof(w)); + for (int round = 0; round < 10; ++round) + { + ChaChaQuarter(w, 0, 4, 8, 12); ChaChaQuarter(w, 1, 5, 9, 13); + ChaChaQuarter(w, 2, 6, 10, 14); ChaChaQuarter(w, 3, 7, 11, 15); + ChaChaQuarter(w, 0, 5, 10, 15); ChaChaQuarter(w, 1, 6, 11, 12); + ChaChaQuarter(w, 2, 7, 8, 13); ChaChaQuarter(w, 3, 4, 9, 14); + } + for (int i = 0; i < 16; ++i) + w[i] += state[i]; + + size_t take = 64; + if (take > length - written) + take = length - written; + for (size_t i = 0; i < take; ++i) + out[written + i] = uint8_t(w[i / 4] >> (8 * (i & 3))); + written += take; + ++state[12]; + } + } + + // ---- X25519 (RFC 7748), curve25519-donna style 25.5-bit limbs ---- + + typedef int32_t fe[10]; + + inline void FeZero(fe h) { for (int i = 0; i < 10; ++i) h[i] = 0; } + inline void FeOne(fe h) { FeZero(h); h[0] = 1; } + inline void FeCopy(fe h, const fe f) { for (int i = 0; i < 10; ++i) h[i] = f[i]; } + inline void FeAdd(fe h, const fe f, const fe g) { for (int i = 0; i < 10; ++i) h[i] = f[i] + g[i]; } + inline void FeSub(fe h, const fe f, const fe g) { for (int i = 0; i < 10; ++i) h[i] = f[i] - g[i]; } + + inline void FeCswap(fe f, fe g, int swap) + { + int32_t mask = -swap; + for (int i = 0; i < 10; ++i) + { + int32_t x = mask & (f[i] ^ g[i]); + f[i] ^= x; + g[i] ^= x; + } + } + + void FeMul(fe h, const fe f, const fe g) + { + int32_t f0 = f[0], f1 = f[1], f2 = f[2], f3 = f[3], f4 = f[4]; + int32_t f5 = f[5], f6 = f[6], f7 = f[7], f8 = f[8], f9 = f[9]; + int32_t g0 = g[0], g1 = g[1], g2 = g[2], g3 = g[3], g4 = g[4]; + int32_t g5 = g[5], g6 = g[6], g7 = g[7], g8 = g[8], g9 = g[9]; + + const int64_t g1_19 = 19 * (int64_t)g1, g2_19 = 19 * (int64_t)g2, g3_19 = 19 * (int64_t)g3, g4_19 = 19 * (int64_t)g4, g5_19 = 19 * (int64_t)g5; + const int64_t g6_19 = 19 * (int64_t)g6, g7_19 = 19 * (int64_t)g7, g8_19 = 19 * (int64_t)g8, g9_19 = 19 * (int64_t)g9; + const int64_t f1_2 = 2 * (int64_t)f1, f3_2 = 2 * (int64_t)f3, f5_2 = 2 * (int64_t)f5, f7_2 = 2 * (int64_t)f7, f9_2 = 2 * (int64_t)f9; + + int64_t h0 = (int64_t)f0 * g0 + (int64_t)f1_2 * g9_19 + (int64_t)f2 * g8_19 + (int64_t)f3_2 * g7_19 + + (int64_t)f4 * g6_19 + (int64_t)f5_2 * g5_19 + (int64_t)f6 * g4_19 + (int64_t)f7_2 * g3_19 + + (int64_t)f8 * g2_19 + (int64_t)f9_2 * g1_19; + int64_t h1 = (int64_t)f0 * g1 + (int64_t)f1 * g0 + (int64_t)f2 * g9_19 + (int64_t)f3 * g8_19 + + (int64_t)f4 * g7_19 + (int64_t)f5 * g6_19 + (int64_t)f6 * g5_19 + (int64_t)f7 * g4_19 + + (int64_t)f8 * g3_19 + (int64_t)f9 * g2_19; + int64_t h2 = (int64_t)f0 * g2 + (int64_t)f1_2 * g1 + (int64_t)f2 * g0 + (int64_t)f3_2 * g9_19 + + (int64_t)f4 * g8_19 + (int64_t)f5_2 * g7_19 + (int64_t)f6 * g6_19 + (int64_t)f7_2 * g5_19 + + (int64_t)f8 * g4_19 + (int64_t)f9_2 * g3_19; + int64_t h3 = (int64_t)f0 * g3 + (int64_t)f1 * g2 + (int64_t)f2 * g1 + (int64_t)f3 * g0 + + (int64_t)f4 * g9_19 + (int64_t)f5 * g8_19 + (int64_t)f6 * g7_19 + (int64_t)f7 * g6_19 + + (int64_t)f8 * g5_19 + (int64_t)f9 * g4_19; + int64_t h4 = (int64_t)f0 * g4 + (int64_t)f1_2 * g3 + (int64_t)f2 * g2 + (int64_t)f3_2 * g1 + + (int64_t)f4 * g0 + (int64_t)f5_2 * g9_19 + (int64_t)f6 * g8_19 + (int64_t)f7_2 * g7_19 + + (int64_t)f8 * g6_19 + (int64_t)f9_2 * g5_19; + int64_t h5 = (int64_t)f0 * g5 + (int64_t)f1 * g4 + (int64_t)f2 * g3 + (int64_t)f3 * g2 + + (int64_t)f4 * g1 + (int64_t)f5 * g0 + (int64_t)f6 * g9_19 + (int64_t)f7 * g8_19 + + (int64_t)f8 * g7_19 + (int64_t)f9 * g6_19; + int64_t h6 = (int64_t)f0 * g6 + (int64_t)f1_2 * g5 + (int64_t)f2 * g4 + (int64_t)f3_2 * g3 + + (int64_t)f4 * g2 + (int64_t)f5_2 * g1 + (int64_t)f6 * g0 + (int64_t)f7_2 * g9_19 + + (int64_t)f8 * g8_19 + (int64_t)f9_2 * g7_19; + int64_t h7 = (int64_t)f0 * g7 + (int64_t)f1 * g6 + (int64_t)f2 * g5 + (int64_t)f3 * g4 + + (int64_t)f4 * g3 + (int64_t)f5 * g2 + (int64_t)f6 * g1 + (int64_t)f7 * g0 + + (int64_t)f8 * g9_19 + (int64_t)f9 * g8_19; + int64_t h8 = (int64_t)f0 * g8 + (int64_t)f1_2 * g7 + (int64_t)f2 * g6 + (int64_t)f3_2 * g5 + + (int64_t)f4 * g4 + (int64_t)f5_2 * g3 + (int64_t)f6 * g2 + (int64_t)f7_2 * g1 + + (int64_t)f8 * g0 + (int64_t)f9_2 * g9_19; + int64_t h9 = (int64_t)f0 * g9 + (int64_t)f1 * g8 + (int64_t)f2 * g7 + (int64_t)f3 * g6 + + (int64_t)f4 * g5 + (int64_t)f5 * g4 + (int64_t)f6 * g3 + (int64_t)f7 * g2 + + (int64_t)f8 * g1 + (int64_t)f9 * g0; + + int64_t c; + c = h0 >> 26; h1 += c; h0 -= c << 26; + c = h1 >> 25; h2 += c; h1 -= c << 25; + c = h2 >> 26; h3 += c; h2 -= c << 26; + c = h3 >> 25; h4 += c; h3 -= c << 25; + c = h4 >> 26; h5 += c; h4 -= c << 26; + c = h5 >> 25; h6 += c; h5 -= c << 25; + c = h6 >> 26; h7 += c; h6 -= c << 26; + c = h7 >> 25; h8 += c; h7 -= c << 25; + c = h8 >> 26; h9 += c; h8 -= c << 26; + c = h9 >> 25; h0 += c * 19; h9 -= c << 25; + c = h0 >> 26; h1 += c; h0 -= c << 26; + + h[0] = (int32_t)h0; h[1] = (int32_t)h1; h[2] = (int32_t)h2; h[3] = (int32_t)h3; h[4] = (int32_t)h4; + h[5] = (int32_t)h5; h[6] = (int32_t)h6; h[7] = (int32_t)h7; h[8] = (int32_t)h8; h[9] = (int32_t)h9; + } + + inline void FeSquare(fe h, const fe f) { FeMul(h, f, f); } + + void FeMulSmall(fe h, const fe f, int32_t s) + { + int64_t h0 = (int64_t)f[0] * s, h1 = (int64_t)f[1] * s, h2 = (int64_t)f[2] * s, h3 = (int64_t)f[3] * s; + int64_t h4 = (int64_t)f[4] * s, h5 = (int64_t)f[5] * s, h6 = (int64_t)f[6] * s, h7 = (int64_t)f[7] * s; + int64_t h8 = (int64_t)f[8] * s, h9 = (int64_t)f[9] * s; + + int64_t c; + c = h0 >> 26; h1 += c; h0 -= c << 26; + c = h1 >> 25; h2 += c; h1 -= c << 25; + c = h2 >> 26; h3 += c; h2 -= c << 26; + c = h3 >> 25; h4 += c; h3 -= c << 25; + c = h4 >> 26; h5 += c; h4 -= c << 26; + c = h5 >> 25; h6 += c; h5 -= c << 25; + c = h6 >> 26; h7 += c; h6 -= c << 26; + c = h7 >> 25; h8 += c; h7 -= c << 25; + c = h8 >> 26; h9 += c; h8 -= c << 26; + c = h9 >> 25; h0 += c * 19; h9 -= c << 25; + c = h0 >> 26; h1 += c; h0 -= c << 26; + + h[0] = (int32_t)h0; h[1] = (int32_t)h1; h[2] = (int32_t)h2; h[3] = (int32_t)h3; h[4] = (int32_t)h4; + h[5] = (int32_t)h5; h[6] = (int32_t)h6; h[7] = (int32_t)h7; h[8] = (int32_t)h8; h[9] = (int32_t)h9; + } + + void FeFromBytes(fe h, const uint8_t* s) + { + int64_t h0 = LoadLe32(s) & 0x3ffffff; + int64_t h1 = (LoadLe32(s + 3) >> 2) & 0x1ffffff; + int64_t h2 = (LoadLe32(s + 6) >> 3) & 0x3ffffff; + int64_t h3 = (LoadLe32(s + 9) >> 5) & 0x1ffffff; + int64_t h4 = (LoadLe32(s + 12) >> 6) & 0x3ffffff; + int64_t h5 = LoadLe32(s + 16) & 0x1ffffff; + int64_t h6 = (LoadLe32(s + 19) >> 1) & 0x3ffffff; + int64_t h7 = (LoadLe32(s + 22) >> 3) & 0x1ffffff; + int64_t h8 = (LoadLe32(s + 25) >> 4) & 0x3ffffff; + int64_t h9 = (LoadLe32(s + 28) >> 6) & 0x1ffffff; + + h[0] = (int32_t)h0; h[1] = (int32_t)h1; h[2] = (int32_t)h2; h[3] = (int32_t)h3; h[4] = (int32_t)h4; + h[5] = (int32_t)h5; h[6] = (int32_t)h6; h[7] = (int32_t)h7; h[8] = (int32_t)h8; h[9] = (int32_t)h9; + } + + void FeToBytes(uint8_t* s, fe h) + { + int32_t h0 = h[0], h1 = h[1], h2 = h[2], h3 = h[3], h4 = h[4]; + int32_t h5 = h[5], h6 = h[6], h7 = h[7], h8 = h[8], h9 = h[9]; + + int32_t q; + q = (19 * h9 + (1 << 24)) >> 25; + q = (h0 + q) >> 26; + q = (h1 + q) >> 25; + q = (h2 + q) >> 26; + q = (h3 + q) >> 25; + q = (h4 + q) >> 26; + q = (h5 + q) >> 25; + q = (h6 + q) >> 26; + q = (h7 + q) >> 25; + q = (h8 + q) >> 26; + q = (h9 + q) >> 25; + h0 += 19 * q; + + int32_t c; + c = h0 >> 26; h1 += c; h0 -= c << 26; + c = h1 >> 25; h2 += c; h1 -= c << 25; + c = h2 >> 26; h3 += c; h2 -= c << 26; + c = h3 >> 25; h4 += c; h3 -= c << 25; + c = h4 >> 26; h5 += c; h4 -= c << 26; + c = h5 >> 25; h6 += c; h5 -= c << 25; + c = h6 >> 26; h7 += c; h6 -= c << 26; + c = h7 >> 25; h8 += c; h7 -= c << 25; + c = h8 >> 26; h9 += c; h8 -= c << 26; + c = h9 >> 25; h9 -= c << 25; + + s[0] = (uint8_t)(h0 >> 0); s[1] = (uint8_t)(h0 >> 8); s[2] = (uint8_t)(h0 >> 16); s[3] = (uint8_t)((h0 >> 24) | (h1 << 2)); + s[4] = (uint8_t)(h1 >> 6); s[5] = (uint8_t)(h1 >> 14); s[6] = (uint8_t)((h1 >> 22) | (h2 << 3)); + s[7] = (uint8_t)(h2 >> 5); s[8] = (uint8_t)(h2 >> 13); s[9] = (uint8_t)((h2 >> 21) | (h3 << 5)); + s[10] = (uint8_t)(h3 >> 3); s[11] = (uint8_t)(h3 >> 11); s[12] = (uint8_t)((h3 >> 19) | (h4 << 6)); + s[13] = (uint8_t)(h4 >> 2); s[14] = (uint8_t)(h4 >> 10); s[15] = (uint8_t)(h4 >> 18); + s[16] = (uint8_t)(h5 >> 0); s[17] = (uint8_t)(h5 >> 8); s[18] = (uint8_t)(h5 >> 16); s[19] = (uint8_t)((h5 >> 24) | (h6 << 1)); + s[20] = (uint8_t)(h6 >> 7); s[21] = (uint8_t)(h6 >> 15); s[22] = (uint8_t)((h6 >> 23) | (h7 << 3)); + s[23] = (uint8_t)(h7 >> 5); s[24] = (uint8_t)(h7 >> 13); s[25] = (uint8_t)((h7 >> 21) | (h8 << 4)); + s[26] = (uint8_t)(h8 >> 4); s[27] = (uint8_t)(h8 >> 12); s[28] = (uint8_t)((h8 >> 20) | (h9 << 6)); + s[29] = (uint8_t)(h9 >> 2); s[30] = (uint8_t)(h9 >> 10); s[31] = (uint8_t)(h9 >> 18); + } + + void FeInvert(fe out, const fe z) + { + // z^(p-2), p-2 = 2^255 - 21 = little-endian {0xEB, FF x30, 0x7F} + static const uint8_t kExponent[32] = + { + 0xEB, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x7F + }; + + fe r; + FeOne(r); + for (int bit = 254; bit >= 0; --bit) + { + FeSquare(r, r); + if ((kExponent[bit >> 3] >> (bit & 7)) & 1) + FeMul(r, r, z); + } + FeCopy(out, r); + } + + // scalarMult: out = scalar * point (RFC 7748 X25519). Returns false for a low-order result. + bool X25519(uint8_t out[32], const uint8_t scalar[32], const uint8_t point[32]) + { + uint8_t k[32]; + memcpy(k, scalar, sizeof(k)); + k[0] &= 248; + k[31] &= 127; + k[31] |= 64; + + uint8_t u[32]; + memcpy(u, point, sizeof(u)); + u[31] &= 127; + + fe x1, x2, z2, x3, z3; + fe a, aa, b, bb, e, c, d, da, cb; + FeFromBytes(x1, u); + FeOne(x2); + FeZero(z2); + FeCopy(x3, x1); + FeOne(z3); + + int swap = 0; + for (int t = 254; t >= 0; --t) + { + int kt = (k[t >> 3] >> (t & 7)) & 1; + swap ^= kt; + FeCswap(x2, x3, swap); + FeCswap(z2, z3, swap); + swap = kt; + + FeAdd(a, x2, z2); // A = x2 + z2 + FeSquare(aa, a); // AA = A^2 + FeSub(b, x2, z2); // B = x2 - z2 + FeSquare(bb, b); // BB = B^2 + FeSub(e, aa, bb); // E = AA - BB + FeAdd(c, x3, z3); // C = x3 + z3 + FeSub(d, x3, z3); // D = x3 - z3 + FeMul(da, d, a); // DA = D * A + FeMul(cb, c, b); // CB = C * B + + FeAdd(a, da, cb); // x3 = (DA + CB)^2 + FeSquare(x3, a); + FeSub(a, da, cb); // z3 = x1 * (DA - CB)^2 + FeSquare(a, a); + FeMul(z3, x1, a); + + FeMul(x2, aa, bb); // x2 = AA * BB + FeMulSmall(a, e, 121665);// z2 = E * (AA + a24 * E) + FeAdd(a, aa, a); + FeMul(z2, e, a); + } + FeCswap(x2, x3, swap); + FeCswap(z2, z3, swap); + + FeInvert(z2, z2); + FeMul(x2, x2, z2); + FeToBytes(out, x2); + + uint8_t acc = 0; + for (int i = 0; i < 32; ++i) + acc |= out[i]; + return acc != 0; + } + + void X25519Base(uint8_t out[32], const uint8_t scalar[32]) + { + uint8_t base[32]; + memset(base, 0, sizeof(base)); + base[0] = 9; + X25519(out, scalar, base); + } + + // ---- Ascension's key derivation (HMAC-SHA256 based, ascens_crypto.py derive_key) ---- + + void DeriveKey(uint8_t* out, size_t size, const uint8_t key[32], const uint8_t* input1, size_t input1Length, + const uint8_t* input2, size_t input2Length) + { + uint8_t interim[32]; + HmacSha256(interim, key, 32, input1, input1Length); + + uint8_t material[96]; + memcpy(material, input2, input2Length); + material[input2Length] = 0x01; + uint8_t result[32]; + HmacSha256(result, interim, 32, material, input2Length + 1); + + if (size <= 32) + { + memcpy(out, result, size); + return; + } + + size_t p = 0; + memcpy(material + p, result, 32); + p += 32; + memcpy(material + p, input2, input2Length); + p += input2Length; + material[p++] = 0x02; + uint8_t result2[32]; + HmacSha256(result2, interim, 32, material, p); + + memcpy(out, result, 32); + memcpy(out + 32, result2, size - 32); + } + + void RandomBytes(uint8_t* out, size_t length) + { + size_t i = 0; + while (i < length) + { + unsigned int value = 0; + if (rand_s(&value) != 0) + value = GetTickCount() ^ uint32_t(uintptr_t(out + i)); + for (int b = 0; b < 4 && i < length; ++b) + out[i++] = uint8_t(value >> (8 * b)); + } + } + + // --------------------------------------------------------------------------------------------- + // Protocol constants (byte-exact copies of the campaign's verified values; see the server's + // AscensionAuth.h where the same arrays appear). + // --------------------------------------------------------------------------------------------- + + const uint8_t KC2[32] = + { + 0x33, 0xba, 0x31, 0x28, 0xee, 0x61, 0x4b, 0x58, 0x45, 0xe0, 0x6b, 0x0d, 0xad, 0x17, 0x6a, 0x9c, + 0x79, 0x34, 0x4d, 0xd7, 0xa7, 0xa1, 0xe2, 0xe8, 0xd8, 0xad, 0x09, 0x7d, 0xa9, 0xb5, 0x7f, 0x01 + }; + const uint8_t NC2[12] = { 0x92, 0x01, 0x00, 0x8e, 0xca, 0xfa, 0x7d, 0x60, 0xe0, 0xac, 0xc8, 0x1e }; + const uint8_t KC3[32] = + { + 0x66, 0xd5, 0x2b, 0x01, 0xe0, 0x06, 0xcd, 0x24, 0x6f, 0x09, 0x00, 0x25, 0xd6, 0x31, 0x2c, 0x62, + 0xd1, 0x3e, 0x84, 0x7c, 0x98, 0x05, 0x95, 0x6a, 0x1c, 0x5a, 0x10, 0x36, 0x4b, 0xaa, 0x7d, 0x82 + }; + const uint8_t IN4[32] = // info: key_derived + { + 0xe8, 0x15, 0x73, 0x9f, 0x8e, 0xc8, 0x10, 0x72, 0x1b, 0x93, 0x55, 0x4c, 0xa2, 0xea, 0xc5, 0x97, + 0xe0, 0x5f, 0x37, 0x52, 0x61, 0xdd, 0x72, 0xff, 0x30, 0x83, 0x7d, 0xf9, 0x51, 0xc7, 0xa5, 0xed + }; + const uint8_t IN5[32] = // info: key_session + { + 0x26, 0x98, 0x6c, 0x8a, 0x73, 0xd2, 0x4b, 0xc4, 0x1c, 0xf3, 0x86, 0xbc, 0xb5, 0x84, 0x92, 0x41, + 0x6f, 0xb5, 0x79, 0x78, 0x4e, 0x19, 0x57, 0x70, 0x1a, 0x88, 0x9d, 0x97, 0xb6, 0x55, 0x01, 0x40 + }; + + // The exe global holding the login name exactly as the client would send it (uppercased, any + // '#...' suffix already stripped by the sender). Ascension.exe is fixed at 0x400000. + const uintptr_t kAccountNameGlobal = 0x00D45CD0; + + // The exe's packet store appender: (store, src, size) with ecx = store. Handles capacity + // growth through the store's own vtable. + typedef void* (__thiscall* BulkAppendFn)(void* store, const void* source, size_t size); + const uintptr_t kBulkAppendAddress = 0x0047B1C0; + + // --------------------------------------------------------------------------------------------- + // Session material. + // --------------------------------------------------------------------------------------------- + + struct Session + { + bool valid; + uint8_t clientPublic[32]; + uint8_t keyDerived[32]; // 'kd', the HMAC key for the server proof + uint8_t keySession[40]; // 'ks40', the 40-byte session key handed to the connection + uint8_t proof[32]; // HMAC-SHA256(keyDerived, "OK") the server must return + }; + + Session g_session = {}; + uint8_t g_serverPublicKey[32] = {}; + bool g_serverKeyValid = false; + + bool ParseServerKey() + { + const char* hex = ASCENSION_NATIVE_LOGON_SERVER_KEY; + if (strlen(hex) != 64) + return false; + for (int i = 0; i < 32; ++i) + { + char hi = hex[i * 2]; + char lo = hex[i * 2 + 1]; + auto nibble = [](char c) -> int + { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + int h = nibble(hi), l = nibble(lo); + if (h < 0 || l < 0) + return false; + g_serverPublicKey[i] = uint8_t((h << 4) | l); + } + return true; + } + + bool DeriveSession(Session& session, const uint8_t secret[32], const uint8_t serverPublic[32]) + { + uint8_t shared[32]; + if (!X25519(shared, secret, serverPublic)) + return false; + + X25519Base(session.clientPublic, secret); + DeriveKey(session.keyDerived, 32, KC3, shared, sizeof(shared), IN4, sizeof(IN4)); + DeriveKey(session.keySession, 40, KC3, shared, sizeof(shared), IN5, sizeof(IN5)); + HmacSha256(session.proof, session.keyDerived, 32, reinterpret_cast("OK"), 2); + session.valid = true; + return true; + } + + // Builds the custom hello record (everything the serializer appends after the exe's [00][08]): + // [size u16][FC F4 F4 E6][tag 16][masked payload][trailer 4] + // payload (612 bytes for a 7-char password): + // version token 256 | "WoW" 0 | 3 3 5 | build u16 | "x86" 0 | "niW" 0 | "SUne" | + // u32 tz bias | ip 4 | login 256 | client public 32 | nonce 12 | password tag 16 | + // password length u32 | ChaCha20(key_derived, nonce) encrypted password + size_t BuildHelloRecord(const uint8_t serverPublic[32], const uint8_t keyDerived[32], const uint8_t clientPublic[32], + const uint8_t nonce[12], const uint8_t tag[16], const uint8_t passwordTag[16], + const char* account, const char* password, uint8_t* out, size_t outCapacity) + { + uint8_t clear[1024]; + memset(clear, 0, sizeof(clear)); + size_t p = 0; + + static const char hexDigits[] = "0123456789abcdef"; + clear[0] = '1'; clear[1] = '|'; clear[2] = '1'; clear[3] = '|'; + for (int i = 0; i < 32; ++i) + { + clear[4 + i * 2] = uint8_t(hexDigits[serverPublic[i] >> 4]); + clear[5 + i * 2] = uint8_t(hexDigits[serverPublic[i] & 0x0F]); + } + p = 256; + + memcpy(clear + p, "WoW\0", 4); p += 4; + clear[p++] = 3; clear[p++] = 3; clear[p++] = 5; + clear[p++] = 0x34; clear[p++] = 0x30; // build 12340, little-endian + memcpy(clear + p, "x86\0", 4); p += 4; + memcpy(clear + p, "niW\0", 4); p += 4; // as the genuine client writes it + memcpy(clear + p, "SUne", 4); p += 4; // as the genuine client writes it + clear[p++] = 60; clear[p++] = 0; clear[p++] = 0; clear[p++] = 0; // timezone bias + clear[p++] = 127; clear[p++] = 0; clear[p++] = 0; clear[p++] = 1; // address (loopback testing) + + size_t accountLength = strnlen(account, 255); + memcpy(clear + p, account, accountLength); + p += 256; + + memcpy(clear + p, clientPublic, 32); p += 32; + memcpy(clear + p, nonce, 12); p += 12; + memcpy(clear + p, passwordTag, 16); p += 16; + + size_t passwordLength = strnlen(password, 255); + clear[p++] = uint8_t(passwordLength); clear[p++] = uint8_t(passwordLength >> 8); + clear[p++] = 0; clear[p++] = 0; + if (passwordLength > 0) + { + uint8_t stream[256]; + ChaCha20Stream(stream, passwordLength, keyDerived, nonce, 1); + for (size_t i = 0; i < passwordLength; ++i) + clear[p + i] = uint8_t(password[i]) ^ stream[i]; + } + p += passwordLength; + + // p == 605 + passwordLength; everything but the last 4 bytes is masked with the transport + // keystream and the 0xED constant, the last 4 bytes go out in clear (as the client sends it). + size_t cipherLength = p - 4; + size_t recordLength = 2 + 4 + 16 + cipherLength + 4; + if (recordLength > outCapacity) + return 0; + + size_t r = 0; + uint32_t sizeField = uint32_t(625 + passwordLength); + out[r++] = uint8_t(sizeField); out[r++] = uint8_t(sizeField >> 8); + out[r++] = 0xFC; out[r++] = 0xF4; out[r++] = 0xF4; out[r++] = 0xE6; + memcpy(out + r, tag, 16); r += 16; + + uint8_t transport[1024]; + ChaCha20Stream(transport, cipherLength, KC2, NC2, 1); + for (size_t i = 0; i < cipherLength; ++i) + out[r + i] = uint8_t(clear[i] ^ transport[i] ^ 0xED); + r += cipherLength; + + memcpy(out + r, clear + cipherLength, 4); r += 4; + return r; + } + + // --------------------------------------------------------------------------------------------- + // The three exe thunk replacements. + // --------------------------------------------------------------------------------------------- + + // 0x8CCE00 - hello serialization. Called by the exe's sender with ecx = connection, + // (store, loginInfo[]) on the stack; the exe then sends everything that was appended. + // (MSVC rejects __thiscall on free functions; __fastcall with a dummy second parameter is + // the standard equivalent - ecx = this, edx ignored, stack arguments and cleanup identical.) + int __fastcall HookHelloSender(void* connection, void* /*edx*/, void* store, void* loginInfo) + { + (void)connection; + if (store == nullptr) + return 0; + + const char* account = reinterpret_cast(kAccountNameGlobal); + const char* password = ""; + if (loginInfo != nullptr) + { + const char* candidate = *reinterpret_cast(reinterpret_cast(loginInfo) + 4); + if (candidate != nullptr) + password = candidate; + } + + Session session; + uint8_t secret[32]; + RandomBytes(secret, sizeof(secret)); + if (!DeriveSession(session, secret, g_serverPublicKey)) + { + AscLog::Printf("native-logon: hello hook: key derivation failed, custom hello NOT written"); + return 0; + } + g_session = session; + + uint8_t nonce[12], tag[16], passwordTag[16]; + RandomBytes(nonce, sizeof(nonce)); + RandomBytes(tag, sizeof(tag)); + RandomBytes(passwordTag, sizeof(passwordTag)); + + uint8_t record[1200]; + size_t recordLength = BuildHelloRecord(g_serverPublicKey, session.keyDerived, session.clientPublic, + nonce, tag, passwordTag, account, password, record, sizeof(record)); + if (recordLength == 0) + { + AscLog::Printf("native-logon: hello hook: record build failed, custom hello NOT written"); + return 0; + } + + reinterpret_cast(kBulkAppendAddress)(store, record, recordLength); + AscLog::Printf("native-logon: custom hello written (%zu bytes, login '%s', password %zu chars)", + recordLength, account, strnlen(password, 255)); + return 0; + } + + // 0x9A83E0 - key material. The exe calls this from its challenge handler with ecx = the auth + // context + 0xA8 and nine stack arguments (the stock version computes the SRP6 session key). + // The custom handshake copies the 40-byte key_session to ecx+0x20 (= auth context + 0xC8). + int __fastcall HookKeyMaterial(void* authContext, void* /*edx*/, int, int, int, int, int, int, int, unsigned int, int) + { + if (!g_session.valid) + { + AscLog::Printf("native-logon: key-material hook: no derived session, failing the handshake"); + return -1; + } + memcpy(static_cast(authContext) + 0x20, g_session.keySession, sizeof(g_session.keySession)); + AscLog::Printf("native-logon: session key (key_session) installed into the auth object"); + return 0; + } + + // 0x9A88C0 - server proof check. The exe's reply handler reads the proof field and calls this + // with (field, size); nonzero return = failure (the handler then reports error 0xB). + int __stdcall HookProofCheck(const uint8_t* field, unsigned int size) + { + if (size != 32) + { + AscLog::Printf("native-logon: proof check: unexpected field size %u (expected 32) - FAIL", size); + return 1; + } + if (!g_session.valid) + { + AscLog::Printf("native-logon: proof check: no derived session - FAIL"); + return 1; + } + + uint8_t difference = 0; + for (int i = 0; i < 32; ++i) + difference |= field[i] ^ g_session.proof[i]; + AscLog::Printf("native-logon: proof check %s (HMAC-SHA256(key_derived, \"OK\"))", + difference == 0 ? "PASSED" : "FAILED"); + return difference == 0 ? 0 : 1; + } + + // --------------------------------------------------------------------------------------------- + // Self-test - pins the primitives to the campaign's live-verified values. If any of this + // fails, Install() refuses to patch anything and the client keeps stock SRP6. + // --------------------------------------------------------------------------------------------- + + bool HexEqual(const uint8_t* data, size_t length, const char* hex) + { + if (strlen(hex) != length * 2) + return false; + for (size_t i = 0; i < length; ++i) + { + char buf[3] = { hex[i * 2], hex[i * 2 + 1], 0 }; + unsigned long value = strtoul(buf, nullptr, 16); + if (data[i] != uint8_t(value)) + return false; + } + return true; + } + + bool SelfTest() + { + bool ok = true; + + // SHA-256 FIPS vector. + { + uint8_t digest[32]; + Sha256(digest, reinterpret_cast("abc"), 3); + ok &= HexEqual(digest, 32, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + } + + // HMAC-SHA256 RFC 4231 case 1. + { + uint8_t key[20]; + memset(key, 0x0b, sizeof(key)); + uint8_t digest[32]; + HmacSha256(digest, key, sizeof(key), reinterpret_cast("Hi There"), 8); + ok &= HexEqual(digest, 32, "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"); + } + + // ChaCha20 with the protocol's transport key - the live client's first 16 bytes. + { + uint8_t stream[16]; + ChaCha20Stream(stream, sizeof(stream), KC2, NC2, 1); + ok &= HexEqual(stream, 16, "71ece9e1b158ae06620fe4a5e6f3f420"); + } + + // X25519 RFC 7748 vectors (base point + Diffie-Hellman). + { + uint8_t scalar[32], expect[32], out[32]; + const char* alicePriv = "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"; + for (int i = 0; i < 32; ++i) + { + char buf[3] = { alicePriv[i * 2], alicePriv[i * 2 + 1], 0 }; + scalar[i] = uint8_t(strtoul(buf, nullptr, 16)); + } + X25519Base(out, scalar); + ok &= HexEqual(out, 32, "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a"); + + const char* bobPub = "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f"; + for (int i = 0; i < 32; ++i) + { + char buf[3] = { bobPub[i * 2], bobPub[i * 2 + 1], 0 }; + expect[i] = uint8_t(strtoul(buf, nullptr, 16)); + } + X25519(out, scalar, expect); + ok &= HexEqual(out, 32, "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742"); + } + + // The full session derivation, modulo the record, against the campaign's live pair + // (the build's default key): secret 22dfa316... against server key 1a03d4c7... + { + const char* secretHex = "22dfa316a864ff398609b0b9ea1edec46ec6f7097a3d215dddfa1674e3d7f0f5"; + uint8_t secret[32]; + for (int i = 0; i < 32; ++i) + { + char buf[3] = { secretHex[i * 2], secretHex[i * 2 + 1], 0 }; + secret[i] = uint8_t(strtoul(buf, nullptr, 16)); + } + uint8_t serverPublic[32]; + for (int i = 0; i < 32; ++i) + { + char buf[3] = { ASCENSION_NATIVE_LOGON_SERVER_KEY[i * 2], ASCENSION_NATIVE_LOGON_SERVER_KEY[i * 2 + 1], 0 }; + serverPublic[i] = uint8_t(strtoul(buf, nullptr, 16)); + } + + Session session; + ok &= DeriveSession(session, secret, serverPublic); + ok &= HexEqual(session.clientPublic, 32, "1a03d4c71f289f39046fad519f8af3e92b793709a8a340e492b4ff5ff55e1537"); + ok &= HexEqual(session.keyDerived, 32, "efed6b4faffe5508e8f4186b31e6e7bfd612ce59170e2c448a66f25f4b82dbea"); + ok &= HexEqual(session.keySession, 40, "2c110c92d89a13491f3f1ef7aa87f13f7dc7846fc938e06f9098e85baf798e7a155ab33992991163"); + ok &= HexEqual(session.proof, 32, "a86c61cf8bfdb8f3ed4c5edd301f1b6219ee1c3ae6f0b25142e7416cc8e35869"); + + // The whole hello record, byte for byte, against the reference implementation. + uint8_t nonce[12], tag[16], passwordTag[16]; + for (int i = 0; i < 12; ++i) nonce[i] = uint8_t(i); + for (int i = 0; i < 16; ++i) tag[i] = uint8_t(16 + i); + for (int i = 0; i < 16; ++i) passwordTag[i] = uint8_t(32 + i); + + uint8_t record[1200]; + size_t length = BuildHelloRecord(serverPublic, session.keyDerived, session.clientPublic, + nonce, tag, passwordTag, "LOCAL", "copilot", record, sizeof(record)); + + static const char kRecordHex[] = + "7802fcf4f4e6101112131415161718191a1b1c1d1e1fad7d35706dd473d8ebd66a7f3a782bf502684f5b194c19910abe6ea4" + "a013b307d8c2ebf3998f3cdca3a29ca76b0698ba1b5cd06ae1f7b37b95f060e919b8a700f585ea18836cc89af0f87710248d" + "81daa1b6163ff88d194ae6c7a5856a5cfbcba3f5cd3f644bb97a7f412ceea511d8c58290ed83a41f909a0199e62b28543438" + "6b0a408dbf89b731a3404b23d173fee87d6cdd6634cdb0079437bc236cd1db346307b234f8ff6fc697c268de45178f8493f0" + "b60f4ce4998f037f6bb385d178ac9e91dfe40aedc7007064d9d74dba5777776835c5001e774d9767e15cc5f8a2b9d51ac0ba" + "f584bf6902a4df54114d87238046d0aee0a32fa155f6868d197968c366cda47f0cfe00341775b497bd170d072a2988b025bd" + "8b470abae873d564e92bae8c06e2b5ab81c32acb0842b237219774c3a0c3f0c5a7c2c26ddf2c1ff33ffaa4dcaa2163229c2d" + "8687eb5891902b8d5f131bb3c6cecc9c95a158199f8553f22138735ee7a96417a90e0cb7a96fd189aed18ba124720068b8b2" + "06f1f0b65d2a397778d15144cd4cf530a16bd2a46dd4e975c48d0ce6488d9bbaedbca73cce9dde67e5d95577c5341fa982a0" + "f726b3fd581e77e355a9670ae88602312b124ecf9caf51a603500b15b8e1daf7c6895d143c56ac7757e8d8e9f258e42d8d0c" + "40782dc714d750f56e36d51e28938921de162cbe7af586fcf32e5bb69f1af225d9b4a375a2ad2a8764bf429251c844710d4c" + "ea5a79c0929f25c70281747599695e4d37842451233beb458401cf51b9a720d81d85e9b9fca8c9513e8d70f88c688af39449" + "5e48f787d61d1f6f5c5c10a8dfec34576d8b9bf768ad2d37d7acb67c9465fafbe88b"; + + ok &= (length == strlen(kRecordHex) / 2) && HexEqual(record, length, kRecordHex); + } + + if (!ok) + AscLog::Printf("native-logon: crypto self-test FAILED"); + return ok; + } + + // --------------------------------------------------------------------------------------------- + // Patch tools (the same protect / write / flush pattern as the other attach code). + // --------------------------------------------------------------------------------------------- + + bool WriteMemory(uint32_t address, const uint8_t* bytes, size_t length) + { + void* target = reinterpret_cast(uintptr_t(address)); + DWORD oldProtect = 0; + if (!VirtualProtect(target, length, PAGE_EXECUTE_READWRITE, &oldProtect)) + return false; + memcpy(target, bytes, length); + VirtualProtect(target, length, oldProtect, &oldProtect); + FlushInstructionCache(GetCurrentProcess(), target, length); + return true; + } + + bool JumpHook(uint32_t site, const uint8_t* expect, size_t expectLength, const void* target, const char* what) + { + const uint8_t* code = reinterpret_cast(uintptr_t(site)); + if (memcmp(code, expect, expectLength) != 0) + { + if (code[0] == 0xE9) + { + AscLog::Printf("native-logon: %s: already patched", what); + return true; + } + AscLog::Printf("native-logon: %s: unexpected bytes at 0x%08X (%02X %02X %02X %02X %02X) - NOT patched", + what, site, code[0], code[1], code[2], code[3], code[4]); + return false; + } + + uint8_t patch[5]; + patch[0] = 0xE9; + int32_t relative = int32_t(intptr_t(target) - intptr_t(site + 5)); + memcpy(patch + 1, &relative, sizeof(relative)); + if (!WriteMemory(site, patch, sizeof(patch))) + { + AscLog::Printf("native-logon: %s: write failed at 0x%08X", what, site); + return false; + } + AscLog::Printf("native-logon: %s: 0x%08X -> %p", what, site, target); + return true; + } + + bool WriteConstant(uint32_t site, const uint8_t* expect, size_t length, const uint8_t* bytes, const char* what) + { + const uint8_t* code = reinterpret_cast(uintptr_t(site)); + if (memcmp(code, bytes, length) == 0) + { + AscLog::Printf("native-logon: %s: already applied", what); + return true; + } + if (memcmp(code, expect, length) != 0) + { + AscLog::Printf("native-logon: %s: unexpected bytes at 0x%08X - NOT patched", what, site); + return false; + } + if (!WriteMemory(site, bytes, length)) + { + AscLog::Printf("native-logon: %s: write failed at 0x%08X", what, site); + return false; + } + AscLog::Printf("native-logon: %s: 0x%08X patched", what, site); + return true; + } +} + +namespace AscNativeLogon +{ + void Install() + { + static bool installed = false; + if (installed) + return; + + AscLog::Printf("native-logon: installing Ascension's own logon protocol (custom auth reconstruction)"); + + if (!SelfTest()) + { + AscLog::Printf("native-logon: self-test failed, not installing (client keeps stock SRP6)"); + return; + } + if (!ParseServerKey()) + { + AscLog::Printf("native-logon: ASCENSION_NATIVE_LOGON_SERVER_KEY is not 64 hex characters, not installing"); + return; + } + g_serverKeyValid = true; + + bool ok = true; + + // The three exe thunks the genuine DLL replaces (byte patterns from the deployed client, + // Ascension.exe D4CA3DD1; Ascension.exe loads at its fixed base 0x400000). + static const uint8_t expectHello[] = { 0x55, 0x8B, 0xEC, 0x51, 0x53, 0x56, 0x8B, 0x75, 0x08 }; // push ebp; mov ebp,esp; push ecx; push ebx; push esi; mov esi,[ebp+8] + static const uint8_t expectKey[] = { 0x55, 0x8B, 0xEC, 0x81, 0xEC, 0x5C, 0x01, 0x00, 0x00 }; // push ebp; mov ebp,esp; sub esp,0x15C + static const uint8_t expectProof[] = { 0x55, 0x8B, 0xEC, 0x83, 0xEC, 0x14, 0x83, 0x7D, 0x0C, 0x14 }; // ...; cmp dword ptr [ebp+0Ch], 14h + ok &= JumpHook(0x008CCE00, expectHello, sizeof(expectHello), &HookHelloSender, "hello serializer 0x8CCE00"); + ok &= JumpHook(0x009A83E0, expectKey, sizeof(expectKey), &HookKeyMaterial, "key material 0x9A83E0"); + ok &= JumpHook(0x009A88C0, expectProof, sizeof(expectProof), &HookProofCheck, "proof check 0x9A88C0"); + + // The four handshake length gates: the reply checker must read and pass 32 bytes (0x20) + // instead of the stock SRP6 20 (0x14). Two inline pushes in the reply handler and two + // out-of-line trampolines in the exe's .local section. + static const uint8_t expectPush14[] = { 0x6A, 0x14 }; + static const uint8_t expectImm14[] = { 0x14, 0x00, 0x00, 0x00 }; + static const uint8_t newPush20[] = { 0x6A, 0x20 }; + static const uint8_t newImm20[] = { 0x20, 0x00, 0x00, 0x00 }; + ok &= WriteConstant(0x008CC7FA, expectPush14, sizeof(expectPush14), newPush20, "proof length gate 0x8CC7FA"); + ok &= WriteConstant(0x008CC856, expectPush14, sizeof(expectPush14), newPush20, "proof length gate 0x8CC856"); + ok &= WriteConstant(0x00DFC024, expectImm14, sizeof(expectImm14), newImm20, "read length trampoline 0xDFC024"); + ok &= WriteConstant(0x00DFC034, expectImm14, sizeof(expectImm14), newImm20, "check length trampoline 0xDFC034"); + + installed = ok; + AscLog::Printf(ok + ? "native-logon: installed (custom X25519/HMAC handshake, server key %s)" + : "native-logon: INCOMPLETE - some sites were left untouched, see the log above", + ASCENSION_NATIVE_LOGON_SERVER_KEY); + } +} + +#else + +namespace AscNativeLogon +{ + void Install() {} +} + +#endif diff --git a/src/Ascension/AscNativeLogon.hpp b/src/Ascension/AscNativeLogon.hpp new file mode 100644 index 0000000..ad9e622 --- /dev/null +++ b/src/Ascension/AscNativeLogon.hpp @@ -0,0 +1,21 @@ +#pragma once +// Ascension's own logon protocol, reconstructed (build option ASCENSION_NATIVE_LOGON, default OFF). +// +// The genuine DLL replaces the client's stock SRP6 logon with Ascension's own handshake by +// hot-patching three exe thunks (0x8CCE00 hello serialization, 0x9A83E0 key material, 0x9A88C0 +// the server-proof check). This module installs a native reconstruction of those three pieces so +// the client speaks the same wire protocol as a server that implements it (CoA.AscensionAuth in +// the azerothcore-wotlk-coa fork). The wire format, constants and derivations are byte-exact +// copies of the values verified live during the 2026-10 native-login campaign (see the repo's +// tests and the reference implementations re/ascens_crypto.py, re/fake_auth8_pure.py, +// re/ascension_auth_probe.py and the server's AscensionAuth.h - keep them in step). +// +// With the option OFF (the default) nothing here is compiled in and the client authenticates +// with stock SRP6, exactly as before. +namespace AscNativeLogon +{ + // Installs the logon protocol: crypto self-test first (refuses to install on failure), then + // the three thunk replacements and the four handshake length gates (0x14 -> 0x20). Safe to + // call more than once. Called from DllMain when the build option is enabled. + void Install(); +} diff --git a/tools/ascension-auth-keygen.ps1 b/tools/ascension-auth-keygen.ps1 new file mode 100644 index 0000000..d8ccc70 --- /dev/null +++ b/tools/ascension-auth-keygen.ps1 @@ -0,0 +1,204 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Generates an X25519 (RFC 7748) key pair as two 64-character hex strings. + +.DESCRIPTION + Prints the private key and its matching public key. By default a fresh + random pair is generated; -FromPrivate derives the public key of an + existing private key instead. + + The X25519 math (Montgomery ladder over 2^255 - 19) is implemented + inline on System.Numerics.BigInteger - no OpenSSL or Python needed - + and is self-tested before every run against the RFC 7748 section 6.1 + vectors and the pair this project's live login tests used, so a passing + self-test ties the output to the implementation working in practice. + +.PARAMETER FromPrivate + Derive the public key of an existing 64-character hex private key + instead of generating a new pair. + +.NOTES + In the Ascension client the public key is the value carried in the KC1 + slot. Both keys are 32 bytes (64 hex characters). + +.EXAMPLE + .\ascension-auth-keygen.ps1 + +.EXAMPLE + .\ascension-auth-keygen.ps1 -FromPrivate 22dfa316a864ff398609b0b9ea1edec46ec6f7097a3d215dddfa1674e3d7f0f5 +#> +[CmdletBinding()] +param( + [ValidatePattern('^[0-9a-fA-F]{64}$')] + [string]$FromPrivate +) + +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +$script:FieldP = [System.Numerics.BigInteger]::Pow(2, 255) - 19 +$script:A24 = [System.Numerics.BigInteger]121665 +$script:BasePoint = New-Object byte[] 32 +$script:BasePoint[0] = 9 + +# ---- byte / hex helpers ----------------------------------------------------- + +function ConvertTo-Bytes([string]$Hex) { + if ($Hex.Length % 2 -ne 0) { throw "hex string has odd length: $Hex" } + $out = New-Object byte[] ($Hex.Length / 2) + for ($i = 0; $i -lt $out.Length; $i++) { + $out[$i] = [Convert]::ToByte($Hex.Substring($i * 2, 2), 16) + } + , $out +} + +function ConvertTo-Hex([byte[]]$Bytes) { + [System.BitConverter]::ToString($Bytes).Replace('-', '').ToLowerInvariant() +} + +function Read-LeBig([byte[]]$Le) { + $padded = New-Object byte[] ($Le.Length + 1) # extra 0 byte keeps the value positive + [Array]::Copy($Le, $padded, $Le.Length) + [System.Numerics.BigInteger]::new($padded) +} + +function Write-BigLe32([System.Numerics.BigInteger]$Value) { + $raw = $Value.ToByteArray() # little-endian, two's complement + $out = New-Object byte[] 32 + [Array]::Copy($raw, $out, [Math]::Min(32, $raw.Length)) + , $out +} + +# ---- field arithmetic mod 2^255 - 19 ---------------------------------------- + +function ModAdd([System.Numerics.BigInteger]$A, [System.Numerics.BigInteger]$B) { ($A + $B) % $script:FieldP } + +function ModSub([System.Numerics.BigInteger]$A, [System.Numerics.BigInteger]$B) { + $r = $A - $B + if ($r -lt 0) { $r += $script:FieldP } + $r +} + +function ModMul([System.Numerics.BigInteger]$A, [System.Numerics.BigInteger]$B) { ($A * $B) % $script:FieldP } + +function ModPow([System.Numerics.BigInteger]$Base, [System.Numerics.BigInteger]$Exponent) { + $r = [System.Numerics.BigInteger]1 + $b = $Base % $script:FieldP + while ($Exponent -gt 0) { + if (($Exponent % 2) -eq 1) { $r = ModMul $r $b } + $b = ModMul $b $b + $Exponent = [System.Numerics.BigInteger]::Divide($Exponent, 2) + } + $r +} + +# ---- X25519 (RFC 7748 section 5) -------------------------------------------- + +function Invoke-X25519([byte[]]$Scalar, [byte[]]$U) { + if ($Scalar.Length -ne 32 -or $U.Length -ne 32) { throw 'X25519 inputs must be 32 bytes' } + + $k = [byte[]]$Scalar.Clone() + $k[0] = $k[0] -band 0xF8 # decodeScalar25519 + $k[31] = ($k[31] -band 0x7F) -bor 0x40 + + $uB = [byte[]]$U.Clone() + $uB[31] = $uB[31] -band 0x7F # decodeUCoordinate: ignore the top bit + $x1 = Read-LeBig $uB + + $x2 = [System.Numerics.BigInteger]1 + $z2 = [System.Numerics.BigInteger]0 + $x3 = $x1 + $z3 = [System.Numerics.BigInteger]1 + $swap = $false + + for ($t = 254; $t -ge 0; $t--) { + $kt = ((($k[$t -shr 3]) -shr ($t -band 7)) -band 1) -eq 1 + if ($swap -ne $kt) { + $x2, $x3 = $x3, $x2 + $z2, $z3 = $z3, $z2 + } + $swap = $kt + + $A = ModAdd $x2 $z2 + $AA = ModMul $A $A + $B = ModSub $x2 $z2 + $BB = ModMul $B $B + $E = ModSub $AA $BB + $C = ModAdd $x3 $z3 + $D = ModSub $x3 $z3 + $DA = ModMul $D $A + $CB = ModMul $C $B + $s = ModAdd $DA $CB + $x3 = ModMul $s $s + $d = ModSub $DA $CB + $z3 = ModMul $x1 (ModMul $d $d) + $x2 = ModMul $AA $BB + $z2 = ModMul $E (ModAdd $AA (ModMul $script:A24 $E)) + } + if ($swap) { + $x2, $x3 = $x3, $x2 + $z2, $z3 = $z3, $z2 + } + + Write-BigLe32 (ModMul $x2 (ModPow $z2 ($script:FieldP - 2))) +} + +# ---- self-test -------------------------------------------------------------- + +function Assert-HexEqual([string]$Name, [byte[]]$Actual, [string]$ExpectedHex) { + $got = ConvertTo-Hex $Actual + if ($got -ne $ExpectedHex) { + throw "self-test failed ($Name):`n got $got`n expected $ExpectedHex" + } +} + +function Test-X25519 { + # RFC 7748 section 5.2: function vectors 1 and 2. + Assert-HexEqual 'rfc-7748-1' (Invoke-X25519 (ConvertTo-Bytes 'a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4') (ConvertTo-Bytes 'e6db6867583030db3594c1a424b15f7c726624ec26b3353b10a903a6d0ab1c4c')) 'c3da55379de9c6908e94ea4df28d084f32eccf03491c71f754b4075577a28552' + Assert-HexEqual 'rfc-7748-2' (Invoke-X25519 (ConvertTo-Bytes '4b66e9d4d1b4673c5ad22691957d6af5c11b6421e0ea01d42ca4169e7918ba0d') (ConvertTo-Bytes 'e5210f12786811d3f4b7959d0538ae2c31dbe7106fc03c3efc4cd549c715a493')) '95cbde9476e8907d7aade45cb4b873f88b595a68799fa152e6f8f7647aac7957' + + # RFC 7748 section 6.1: Diffie-Hellman vectors (key pairs and both shared secrets). + $alicePriv = ConvertTo-Bytes '77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a' + $alicePub = ConvertTo-Bytes '8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a' + $bobPriv = ConvertTo-Bytes '5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb' + $bobPub = ConvertTo-Bytes 'de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f' + Assert-HexEqual 'rfc-7748-alice' (Invoke-X25519 $alicePriv $script:BasePoint) '8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a' + Assert-HexEqual 'rfc-7748-bob' (Invoke-X25519 $bobPriv $script:BasePoint) 'de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f' + Assert-HexEqual 'rfc-7748-shared' (Invoke-X25519 $alicePriv $bobPub) '4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742' + Assert-HexEqual 'rfc-7748-symmetric' (Invoke-X25519 $bobPriv $alicePub) '4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742' + + # The pair this project's live login tests used (handshakes passed in both roles with + # it) - ties this implementation to the one working in practice. + Assert-HexEqual 'project-live-pair' (Invoke-X25519 (ConvertTo-Bytes '22dfa316a864ff398609b0b9ea1edec46ec6f7097a3d215dddfa1674e3d7f0f5') $script:BasePoint) '1a03d4c71f289f39046fad519f8af3e92b793709a8a340e492b4ff5ff55e1537' +} + +# ---- main ------------------------------------------------------------------- + +Write-Host 'X25519 self-test (RFC 7748 vectors + the project live-test pair)...' +Test-X25519 +Write-Host 'self-test: PASS' -ForegroundColor Green + +if ($FromPrivate) { + $priv = ConvertTo-Bytes $FromPrivate + Write-Host 'mode: derive the public key from the supplied private key' +} +else { + $priv = New-Object byte[] 32 + [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($priv) + Write-Host 'mode: generate a new key pair' +} + +$pub = Invoke-X25519 $priv $script:BasePoint +$privHex = ConvertTo-Hex $priv +$pubHex = ConvertTo-Hex $pub + +Write-Host '' +Write-Host 'Private key:' +Write-Host " $privHex" +Write-Host 'Public key:' +Write-Host " $pubHex" +Write-Host '' +Write-Host 'Notes:' +Write-Host ' - X25519 (RFC 7748); both values are 32 bytes / 64 hex characters.' +Write-Host ' - Keep the private key secret; the public key is the shareable one.'