Files
kiloparsec/CONCEALED-PROTOCOL-TIMING.md

15 KiB

Concealed protocol timing assessment

Recorded 2026-10-22. This evaluates the current Elligator call graph, measures its implementation, and tests the proposed wall-clock roundup policy. It is not a demonstration of a private-key recovery attack or a proof that no side channel exists.

1. Correcting the original concern

The implementation is variable-time, but the original warning did not adequately distinguish public-input variation from secret-dependent leakage.

  • The server calls decode() on the representative received in HELO. All bignum inputs in this path are public wire data. The long-lived server scalar enters libsodium's DH operation after that mapping; it does not enter the bignum implementation.
  • The client uses libsodium to generate an ephemeral point, then bignum to convert/map its public coordinates. The successful Montgomery coordinate is recoverable from the transmitted representative. The low three torsion-selection bits are cleared by ordinary X25519 and do not contribute to its secret scalar.
  • The client retries with independent random candidates when the inverse mapping fails. Discarded candidates are not reused. Elligator's key-exchange reference explicitly permits this variable retry count for properly random ephemeral keys.
  • Client preparation precedes the first HELO. Measuring it remotely requires an independent connection-start reference. Server mapping affects the response path and is directly measurable in principle, but its inputs are public.

Consequently, these measurements must not be described as evidence that our server's private key leaks through bignum arithmetic. Current dataflow does not establish such a leak. This conclusion is based primarily on which values the arithmetic consumes, not on a low statistical score. Future changes that put secret scalars or secret-dependent hidden coordinates into this arithmetic require a new assessment.

2. Measurement method and scope

Host: Intel Core Ultra 9 275HX, 24 logical processors, Linux 7.1.8-legion-audio x86_64. JVM: OpenJDK 21.0.12. Kotlin 2.4.0. The original Native measurements below use the Linux x64 debug test binary. Subsequent optimized release results and JVM allocation profiling are recorded in the snapshot qualification report.

The Elligator probe uses 64 warm-up rounds and 256 measured samples per class. Class order is shuffled each round with a fixed seed. The eight torsion cases share the same clamped scalar within each round; only the three low bits differ. Received representatives include random public inputs, a zero boundary case, and paired encodings differing only in ignored high bits. Inverse-map cases compare reference inputs that are representable or rejected. Generation uses fresh synthetic randomness; a separate equivalent loop records rejection counts. No key bytes are logged.

The JVM handshake probe performs all four actual cryptographic flights with fresh ephemeral keys and fixed synthetic identities, including the prefix parsing used by the connection. It excludes socket IO, application hooks, admission/queue waits and artificial delays. Its first sample is not a process-cold benchmark: earlier tests can have initialized classes. Padding is disabled to isolate cryptographic work.

Monotonic clocks measure elapsed time. Mean, median, p95, p99, maximum and exploratory Welch t statistics are retained with raw samples. Measurements are a single host run, without CPU isolation, a controlled thermal/frequency profile or adversarial co-resident load. GC/JIT, scheduler activity, autocorrelation and multiple comparisons prevent interpreting these numbers as a constant-time certificate.

3. Measured results

All times below are milliseconds; 256 measurements per listed group.

Runtime Operation Median p99 Maximum
JVM Decode random public representative 2.790 4.197 5.872
JVM Encode representable public coordinate 5.262 8.030 10.574
JVM Reject unrepresentable public coordinate 2.503 3.808 5.925
JVM Generate fresh Elligator key pair 7.603 27.407 34.354
Native debug Decode random public representative 77.316 79.015 89.529
Native debug Encode representable public coordinate 147.269 149.589 181.251
Native debug Reject unrepresentable public coordinate 70.917 72.753 107.829
Native debug Generate fresh Elligator key pair 154.524 537.195 766.529
JVM Client HELO preparation 5.138 22.171 29.364
JVM Server prefix authentication 2.494 4.775 5.087
JVM Server HELO response construction 2.680 4.846 5.335
JVM All four crypto flights, without IO 11.063 27.966 35.193

The eight matched torsion classes had median special-public-key times of 0.335–0.342 ms on JVM and 6.105–6.198 ms on Native debug. Exploratory maximum absolute Welch t against torsion class zero was 0.99 and 2.02 respectively. Those small scores are not evidence of constant-time execution; the accepted/rejected public-input encoding branches are deliberately distinguishable (t approximately 57 on JVM and 371 on Native).

The separate instrumented generation loop observed 1–12 candidates on JVM and 1–13 on Native debug in 256 runs each. This explains expected rejection-related tails. Its largest observed Native debug generation time was 1.075 seconds; this is a separate sample set from the actual-generator row above.

The revised handshake probe's first-in-probe sample took 198.175 ms. It was not a controlled process-cold experiment, and startup/JIT/initialization effects were not separately attributed. The remaining warm samples are shown in the table. Neither 50 ms nor the observed warm maximum is a guaranteed execution bound.

Server prefix parsing and subsequent complete-HELO construction currently perform separate public Elligator mappings. Their costs therefore both belong in the actual response path. These measurements do not include the newly added failed-authentication decoy; that cost is measured separately in section 7.

4. Exact roundup-plus-jitter experiment

The tested policy is the user's actual proposal, rather than simply adding noise to the current time:

release = ceil(completion / 50 ms) * 50 ms + J
J is uniformly selected from {0, 1, ..., 10} whole milliseconds

A deterministic test enumerates all 50,000 microsecond start phases and all eleven jitter choices. For 2 ms versus 3 ms computations, the mean target release still differs by exactly 1 ms. Subtracting the same mean start time gives the same elapsed-latency difference. Jitter broadens the distributions but does not remove that mean difference under this phase model.

A boundary example is simpler: start at grid phase 47.5 ms. A 2 ms calculation completes at 49.5 ms and releases in 50–60 ms; a 3 ms calculation completes at 50.5 ms and releases in 100–110 ms. These supports are disjoint even after whole-millisecond jitter. Controlling or learning the relevant phase is an additional attacker capability; the example establishes a limitation of the mitigation, not an attack against our handshake.

An operation-start deadline selected before calculation, such as start + 50 ms + J, gives the same target distribution for both calculations while they fit the budget. Work exceeding the deadline, correlated scheduler delays, and local cache/CPU-contention observations remain outside that guarantee. Rounding the elapsed operation duration into 50 ms buckets also works only while the compared durations stay in the same bucket.

The coroutine experiment uses synthetic 2/3 ms CPU work, 100 samples per class per policy, randomized order and independently randomized grid phase before each sample. It uses a fixed monotonic grid, equivalent to a fixed wall-clock grid up to its origin for this experiment, without adjustments of the system wall clock. Jitter is an integer number of milliseconds. Cancellable delays round the remaining wait up to milliseconds and recheck the deadline; scheduler overshoot is measured rather than assumed absent. This is a scheduling experiment, separately labelled from the real Elligator measurements.

Synthetic CPU work Unshaped mean Completion-grid mean Start-selected deadline mean
2 ms 2.006 ms 32.465 ms 54.527 ms
3 ms 3.006 ms 34.590 ms 55.061 ms

No compute-budget overruns occurred in these synthetic 2/3 ms runs. The 100-sample live comparisons after shaping had absolute Welch t near 1.00 (completion grid) and 1.18 (start deadline), compared with approximately 2574 unshaped. This illustrates how much harder a single small sample set becomes to distinguish. It does not invalidate the exact boundary model or establish equal distributions: sampled phases/jitter and scheduler overshoot differ between classes. The exact model, rather than the noisy observed mean gap, establishes the residual grid-boundary effect.

5. Interpretation and next decisions

  1. Variable execution time is confirmed. Different public inputs and accepted/rejected inverse-map branches have different costs. That fact alone is not a private-key disclosure.
  2. No demonstrated secret leak has been found in the current bignum dataflow. The timing warning should be narrowed accordingly. This does not independently audit libsodium, platform bindings, the custom handshake or co-resident side channels.
  3. Grid roundup is remote timing shaping, not full erasure. It can make individual timings much less informative. Bucket crossings remain, and independent random extra work, including random bignum divisions, does not supply a general elimination guarantee. Research also demonstrates that concurrent response ordering can expose timing differences despite network noise: Van Goethem et al., USENIX Security 2020.
  4. Do not infer a universal 50 ms budget from this host. Native debug timings, cold startup, device speed and load differ. Any production deadline policy needs configurable bounds, consistent externally observable success/failure handling, explicit overruns, cancellation and admission limits. Waiting must occur outside the cryptographic semaphore. A global release grid can itself introduce a periodic traffic pattern; this has not been tested as a network fingerprint here.
  5. No clock-padding or random-division policy was added. Following the subsequent user request, one bounded decoy decryption was added to the HELO authentication-failure path; section 7 records its exact behavior and cost. It is not an authentication upgrade or a general timing-concealment guarantee. An independent protocol review and the remaining fuzz/platform qualification are still required.

6. Reproduction and artifacts

Ordinary test runs do not execute the expensive sampling loops. Enable them explicitly:

KILOPARSEC_TIMING_PROBE=1 KILOPARSEC_TIMING_SAMPLES=256 \
KILOPARSEC_TIMING_WARMUP=64 \
./gradlew '-Dorg.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=1g' \
  jvmTest --tests '*Timing*' linuxX64Test --tests '*ElligatorTiming*' --console=plain

KILOPARSEC_SHAPING_SAMPLES controls the live synthetic sample count per class/policy (default 100). Fresh randomness in actual generation, CPU state and scheduling mean that repeating the command will not reproduce identical timings. A repeated invocation with unchanged source may be considered up-to-date by Gradle; add --rerun-tasks when a fresh sampling run is intended. That also reruns compilation prerequisites.

Local ignored artifacts under build/concealed-timing/ contain jvm.csv, linuxX64.csv, handshake-jvm.csv, shaping-samples.csv, shaping-summary.txt and host/run metadata. The tables above preserve the relevant summary in versionable documentation; raw samples remain local build output.

7. Requested server-side decoy decryption

Each ConcealedServerIdentity now owns a separate random 32-byte throwaway scalar seed. When the real HELO envelope authentication throws UnauthenticatedFlight, the wrapper copies the seed, flips 1–8 distinct effective X25519 scalar bits (positions 3 through 253), and performs one additional envelope-decryption attempt with that copy. The temporary copy and mutation entropy are cleared afterward. The persistent seed and the real server key are unchanged; concurrent attempts use independent copies.

The dummy outcome is discarded even if it succeeds. It cannot create a response, invoke identity hooks, advance real handshake state or turn a failed authentication into success. Expected dummy authentication/parsing failures preserve the original failure. Cancellation, unexpected runtime failures and fatal errors propagate. Bad lengths, low-order peer inputs and admission rejection do not trigger an envelope-authentication decoy. Client ACK/session-record failures are not part of this HELO-specific mechanism.

Both prefix and full-envelope failure paths run inside the existing server crypto admission limit. There is no recursive retry, added coroutine delay, random division loop or added unauthenticated response. The local server option ConcealedOptions.Handshake.decoyOnHelloAuthenticationFailure defaults to true; set it to false to omit the extra attempt. It is not negotiated and does not change the wire format.

A separate JVM probe measured 64 samples per class after 16 warmups, randomized across the same eight synthetic HELOs. It exercises the actual identity/admission wrapper and real envelope authentication, without network IO:

Case Median disabled Median enabled Mean added work
Valid header 2.488 ms 2.481 ms -0.034 ms
Bad header authentication 2.501 ms 4.981 ms 2.435 ms
Bad body authentication 2.584 ms 5.109 ms 2.646 ms

This approximately doubles failed-HELO CPU work on the measured JVM, while successful authentication adds no dummy work. X25519 is designed to be constant-time and the repeated Elligator decode consumes the same public representative; changing the dummy scalar therefore does not itself imply broad random timing variation. No Intel microarchitectural attack reproduction was performed, and effectiveness against the user's previously observed attacks has not been established by these tests.

Five new unit tests cover exact invocation bounds, successful dummy-result discard, expected versus unexpected exceptions, effective-bit mutation and erasure, concurrent copy independence, both actual failure stages, and preservation of real keys and a later valid handshake. Existing connection regression tests cover UDP injected noise and readiness behavior. The five new tests plus six connection tests passed on JVM, Kotlin/JS and Linux Native (11 each) after the production change; Android release and Wasm compilation also passed. The actual-path JVM failure timing probe passed separately.

The additional opt-in probe is HelloFailureTimingProbeTest; output is build/concealed-timing/helo-failure-jvm.csv. Run with KILOPARSEC_TIMING_PROBE=1; KILOPARSEC_FAILURE_TIMING_SAMPLES controls samples per class (default 64). The earlier *Timing* JVM filter includes it on subsequent fresh runs.