Commit Graph

13270 Commits

Author SHA1 Message Date
Claude df6103ffdd fix(quic): PSL subset, JCA ChaCha20-Poly1305, truthful ECN reporting
Round 13 — three architectural follow-ups + a closeout note.

* JdkCertificateValidator: ship a hand-picked Public Suffix List
  subset covering the high-volume multi-label effective-TLDs
  (multi-tenant ccTLDs and major hosting platforms). Pre-fix the
  dot-count heuristic accepted `*.co.uk`, `*.s3.amazonaws.com`,
  `*.github.io`, etc. — wildcards spanning these would impersonate
  every co-tenant. The new MULTI_LABEL_PUBLIC_SUFFIXES set adds a
  layer above the dot-count check; combined with the WebPKI / CT
  ecosystem already requiring CAs to consult the full PSL when
  issuing, this closes the practical attack surfaces. Full
  ~9000-entry PSL data shipping is still deferred (data-shipping
  ask, doc'd); a domain not in the subset that's also a multi-label
  ETLD remains a gap.

* JcaChaCha20Poly1305Aead: new JCA-backed implementation mirroring
  JcaAesGcmAead's shape (cached Cipher + SecretKeySpec, range
  overloads via Cipher.doFinal(input, off, len, output, outOff),
  recent-nonce history for legitimate IV reuse on the
  Initial-padding rebuild path). bestChaCha20Poly1305Aead(key)
  expect/actual factory tries the JCA path first (Java 11+ /
  Android API 28+) and falls back to the pure-Kotlin
  ChaCha20Poly1305Aead singleton if the algorithm isn't available
  (older Android, headless GraalVM native-image without the
  standard providers). PacketProtectionBuilder routes the
  ChaCha20-Poly1305 cipher suite through the factory instead of
  the singleton. On supporting platforms this gives the same
  outbound-allocation savings as round 8's AES-GCM range overload.

* QuicConnectionWriter: stop emitting fake ECN counts on ACK
  frames. Pre-fix every 1-RTT ACK carried `AckEcnCounts(0, 0, 0)`
  — claiming to track ECN while actually never reading inbound TOS
  bits. RFC 9000 §13.4.2: "An endpoint that uses ECN MUST report
  accurate ECN counts." Hardcoded zeros could be flagged as a
  PROTOCOL_VIOLATION by strict peers cross-validating against
  outbound packet counts; aioquic / picoquic / quic-go tolerate
  it but other stacks may not. With ecnCounts = null we honestly
  advertise "this endpoint isn't reporting ECN", peer skips its
  own ECN-driven congestion logic for our direction. We still
  mark outbound ECT(0) (other peers' tracking benefits from the
  path-quality signal); RFC 9000 §13.4 allows the asymmetry.

* MutableSharedFlow migration for QuicStream.incoming declined as
  obviated. The audit's suggestion was a workaround for the
  cancel-coupling specifically (collector cancel → channel cancel
  → INTERNAL_ERROR) — round 11's `flow { for (c in
  incomingChannel) emit(c) }` wrapper solved that. Switching to
  MutableSharedFlow would change the semantics from "each byte to
  exactly one consumer" (correct for stream bytes) to fan-out
  (every emission to all collectors), which is wrong for QUIC
  stream data.

All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 40s.

https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
2026-05-09 01:06:32 +00:00
Claude 953869714b fix(quic): visibility, scratch caching, retransmit coalescing, secret hygiene
Round 12 — six smaller follow-ups across visibility, perf, and secret-handling.

* QuicStream.receiveDirtyForFlowControl gains @Volatile. The parser's
  read-loop writes the flag and the writer's send-loop reads it
  WITHOUT holding the same lock; without volatile the writer could
  miss the parser's update for an unbounded time on JVM (the field is
  hot in a drain loop where the JIT might cache it), suppressing
  MAX_STREAM_DATA emissions until something else triggered a fresh
  load.

* SendBuffer.readableBytes runs in O(1) instead of O(R) by maintaining
  a cached `retransmitTotalBytes` counter. Updated in lockstep with
  every retransmit deque mutation: addLast in [requeueAllInflight] +
  the two paths in [removeOverlap] (RETRANSMIT zero-length + main
  range), and add/removeFirst in [takeChunk]. Pre-flight "anything to
  send?" check on the writer's hot path was previously walking the
  deque per-stream per-drain.

* SendBuffer.requeueAllInflight coalesces adjacent ranges on insert.
  Pre-fix the PTO probe path appended each in-flight range as a
  separate retransmit entry, so on the next drain takeChunk emitted
  one tiny STREAM frame per original-packet boundary. With
  coalescing, contiguous bytes get replayed as one chunk + one AEAD
  seal. FIN-bearing ranges stay separate (merging across a FIN
  changes the implicit final-size invariant).

* TlsResumptionState dropped `data class`. The auto-generated
  equals/hashCode used reference equality on its ByteArray fields
  (PSK / ticket / peerTransportParameters), so two byte-identical
  states compared unequal — almost never useful and a footgun for
  caller-side caches. The auto-toString would dump PSK contents into
  any log. Replaced with hand-written equals/hashCode using
  contentEquals on the byte fields and a redacted toString that
  reveals only sizes.

* QuicConnectionParser RESET_STREAM handler bounds finalSize at
  [0, 2^62-1] per RFC 9000 §16 (the QUIC offset ceiling). Pre-fix
  we accepted any varint, including values that could overflow
  downstream Long math.

* PathChallenge/PathResponse IAE leak: re-traced and verified
  non-issue. The decoder calls `r.readBytes(8)`, which either throws
  QuicCodecException (short read) or returns exactly 8 bytes — the
  constructor's `require(data.size == 8)` is unreachable from the
  decode path. The audit was over-cautious; no code change.

All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 39s.

https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
2026-05-09 00:59:28 +00:00
Claude e7b7d99582 perf(quic): outbound AEAD allocation, scratch reuse, sort skip + key-update + flow
Round 11 — six follow-ups across perf, correctness, and API hygiene.

* QuicStream.incoming: replace `consumeAsFlow()` with `flow { for (c in
  incomingChannel) emit(c) }`. Pre-fix the consume-style flow
  cancelled the underlying Channel when the collector terminated,
  which coupled "application stopped reading" with "parser
  INTERNAL_ERRORs the connection on next delivery". The new emit-only
  flow leaves the channel open across collector cancellation, so
  applications can stop reading temporarily and resume later
  (sequential collects, not concurrent — that's still a race on the
  channel iterator).

* RFC 9001 §6.1 client-initiated key update: the existing
  [QuicConnection.initiateKeyUpdate] no longer requires the caller
  to enforce spec invariants. Returns false if the handshake isn't
  yet complete (§6.5: MUST NOT initiate before HANDSHAKE_DONE) or if
  a previous rotation is still in flight (§6.4: MUST NOT initiate a
  subsequent update until the previous one is confirmed). The parser
  clears the [keyUpdateInProgress] flag on the first inbound packet
  that AEAD-decrypts under the post-rotation live keys — the
  confirmation signal that the peer has rolled forward.

* Aead.sealInto: new range + in-place seal that writes ciphertext+tag
  DIRECTLY into a caller-supplied output buffer at a given offset.
  Default impl falls back to sealRange + copy; JcaAesGcmAead overrides
  to use Cipher.doFinal(input, inOff, inLen, output, outOff).
  LongHeaderPacket.build / ShortHeaderPacket.build now pre-allocate
  the final packet buffer in a single shot and have the AEAD write
  ciphertext+tag in-place. Pre-fix every outbound packet allocated
  4 ByteArrays (headerBytes, paddedPlaintext, ciphertext, concat
  buffer); now ~2 (the final packet + the AEAD provider's internal
  scratch).

* QuicConnectionWriter.drainOutbound: skip the
  `active.sortedByDescending { priority }` allocation when EVERY
  active stream shares the same priority — not just the
  default-zero case. A homogeneous priority-7 workload now keeps
  insertion order at no cost.

* QuicConnection.scratchAppFrames / scratchAppTokens: per-connection
  reusable lists for buildApplicationPacket. Cleared at function
  entry, re-used across drains under streamsLock's single-writer
  guarantee. The tokens list is `.toList()`-snapshotted into the
  SentPacket record before reuse, so retransmit dispatch is
  unaffected. NOT applied to collectHandshakeLevelFrames because
  its returned [HandshakeLevelContents] is held across two
  buildLongHeaderFromFrames calls (natural-size + padded rebuild)
  in drainOutbound.

* Removed dead `parts = mutableListOf<ByteArray>()` declaration at
  the top of drainOutbound — never referenced; the actual datagram
  assembly uses inline `listOfNotNull(...)` instead.

All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 43s.

https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
2026-05-09 00:50:36 +00:00
Claude b097580fdd fix(quic): TLS PSK rejection — recover in-place instead of failing
Round 10 — supersedes the typed-exception approach from round 9.

Round 9 introduced [PskRejectedException] as a "punt to the application
layer" hack, with a comment claiming the in-place fallback was too
subtle to land safely. After tracing the actual derivation path the
fix turns out to be one line.

The key observation: the on-wire ClientHello (with `pre_shared_key`
extension and binder bytes) goes into BOTH client and server
transcript hashes regardless of accept / reject (RFC 8446 §4.2.11).
The transcript hash itself doesn't need any rebuild. The ONLY thing
that differs between accept and reject is how [earlySecret] is
derived:

  * accepted: HKDF-Extract(0, PSK)
  * rejected: HKDF-Extract(0, 0)  ← same as no-resumption path

So when the server returns ServerHello without `pre_shared_key`,
we simply call `keySchedule.deriveEarly()` to overwrite the
PSK-derived [earlySecret] with the zero-keyed value. [deriveHandshake]
runs immediately after with the new earlySecret + ECDHE shared
secret, and the handshake proceeds along the regular non-resumption
path (which is well-tested by every non-resumption test in the
suite).

* `pskAccepted = false` so the
  WAITING_CERTIFICATE_OR_FINISHED state correctly demands
  Certificate + CertificateVerify (a Finished without those would
  still be rejected as unauthenticated).
* Any 0-RTT packets the application emitted under the
  PSK-derived [clientEarlyTrafficSecret] are lost — server can't
  decrypt them and EncryptedExtensions arrives without the
  early_data extension, so [earlyDataAccepted] = false. The
  application layer is responsible for replaying any 0-RTT-bound
  payload over 1-RTT. The handshake itself proceeds cleanly.
* [PskRejectedException] (added in round 9) deleted as dead code.
  [QuicCodecException] reverts to a `final` class.

All 269 :quic:jvmTest tests pass. The fallback re-uses the
deriveEarly() codepath that every non-resumption test exercises,
so test coverage is implicit in the existing suite.

https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
2026-05-09 00:33:09 +00:00
Claude 28c1a355da fix(quic): RFC 9002 §6.1.2 loss-detection timer + PSK rejection signal
Round 9 — the two largest items remaining from the audit.

* RFC 9002 §6.1.2 timer-driven loss detection. [detectAndRemoveLost]
  now returns a [Result] data class carrying both the list of lost
  packets and the absolute monotonic deadline at which the next
  earliest sub-largest in-flight packet will cross the time threshold.
  The parser stores that deadline on each [LevelState.nextLossTimeMs]
  per encryption level, and the driver's send loop now sleeps for
  `min(ptoDeadline, minNextLossTimeAcrossLevels) - now`. On expiry,
  the driver distinguishes:
   * Loss-timer wake → run [detectAndRemoveLost] across all levels;
     declare time-threshold-lost packets and dispatch their tokens.
     No probe budget, no exponential backoff.
   * PTO wake → existing [handlePtoFired] path (probe + backoff).
  Pre-fix tail loss waited for the full PTO (often 5x the time
  threshold) before retransmitting because we had no event between
  ACK arrivals to fire loss detection. Now `9/8 * max_rtt` is the
  ceiling.

* TLS PSK rejection: instead of the prior generic [QuicCodecException]
  ("server rejected PSK; full-handshake fallback not implemented"),
  raise a typed [PskRejectedException] subclass. Application reconnect
  logic can selectively catch this and retry the handshake without
  cached resumption state — a path that's correct by construction
  (fresh ClientHello, no PSK history, no transcript-rebuild
  complexity). [QuicCodecException] is now `open` so the subclass
  can extend it.

  In-place fallback (clear early secret, rebuild transcript without
  PSK extension, replay derivation) remains deferred — the subtle
  transcript-hash discrepancies it could introduce would be much
  harder to debug than a hard failure that the application
  intentionally turns into a retry.

All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 47s.

https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
2026-05-09 00:23:37 +00:00
Claude 90f59a3351 perf(quic): AEAD range overload + doc notes for known fragile couplings
Round 8 — AEAD allocation reduction on the receive hot path, plus the
documentation of two known-fragile-but-not-broken couplings.

* Aead gains [openRange] / [sealRange] taking (array, offset, length)
  triples for AAD and plaintext/ciphertext. Default impls slice and
  delegate to the existing whole-array methods so non-JCA AEADs
  (Aes128Gcm singleton, ChaCha20Poly1305Aead) still work. The
  JCA-backed [JcaAesGcmAead] overrides both, passing offsets straight
  to `Cipher.updateAAD(byte[], offset, len)` and
  `Cipher.doFinal(byte[], inputOffset, inputLen)` — JCA accepts ranges
  natively and does no internal copies. Saves ~2 KB ByteArray
  allocations per inbound packet (one for the header `aad`, one for
  the `ciphertext`) — at audio-room receive rates (~100 packets/sec)
  that's ~12 MB/min of GC churn eliminated. Same overload on the
  outbound path is wired but currently exercised less because the
  build path constructs `headerBytes` and `paddedPlaintext` as
  separate fresh allocations.

  parseAndDecrypt in both ShortHeaderPacket and LongHeaderPacket now
  call openRange instead of slicing into intermediates.

* JdkCertificateValidator.dnsMatches: explicit doc note on the
  public-suffix-list gap. The dot-count heuristic accepts wildcards
  like `*.co.uk` / `*.github.io` / `*.s3.amazonaws.com` whose effective
  TLD spans multiple labels. WebPKI / CT logging mitigates this in
  practice (CAs validate against the actual PSL), but our local
  validation alone wouldn't catch a rogue cert. Production callers
  for sensitive endpoints should rely on OS / NetworkSecurityConfig
  pinning rather than QUIC's hostname check alone. Full PSL data
  shipping deferred until justified.

* QuicStream.incoming: explicit doc note on the single-collector
  contract. [consumeAsFlow] cancels the underlying [Channel] when
  its collector terminates, and once cancelled the parser's next
  trySend fails, sets [overflowed] = true, and the parser tears down
  the connection with INTERNAL_ERROR. Production callers already
  follow single-collector + collect-until-FIN, but the doc lays out
  the rule so a future refactor doesn't loosen it accidentally.
  Long-form discussion of the `MutableSharedFlow` alternative and
  why we declined it.

All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 40s.

https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
2026-05-09 00:17:19 +00:00
Claude f987b3dfe2 fix(quic): SETTINGS validation, sensitive headers, peer-stream count, drain alloc
Round 7 — closing out the audit's spec/correctness/perf quick wins.

* WtPeerStreamDemux: validate peer SETTINGS includes ENABLE_WEBTRANSPORT=1
  AND ENABLE_CONNECT_PROTOCOL=1 (draft-ietf-webtrans-http3 §3 +
  RFC 8441). Pre-fix we accepted any SETTINGS and proceeded to
  Extended CONNECT, which the server then rejects with no
  diagnostic for the application. Now surfaces as a typed protocol
  error on peerH3ProtocolError so the QUIC layer closes deliberately.

* QpackEncoder: set N=1 (never-indexed) on literal field lines for
  authorization / cookie / set-cookie / proxy-authorization per
  RFC 9204 §4.5.4. Pre-fix sensitive credentials could be cached
  by intermediate QPACK encoder caches.

* QuicConnection.getOrCreatePeerStreamLocked: track peerInitiated*Count
  via max(current, peerIndex+1) instead of += 1. The counter now
  derives from the stream id's index field (RFC 9000 §2.1) and is
  idempotent against retransmits-after-eviction. Pre-fix a peer
  retransmit on a stream id that aged out of the retired-IDs FIFO
  bumped the counter again, eventually triggering spurious
  MAX_STREAMS_* emissions.

* applyPeerRetireConnectionIdLocked: reclassify the close-on-seq=0
  case as PROTOCOL_VIOLATION (peer fault) instead of INTERNAL_ERROR
  (our fault). The diagnostic was misleading — the peer IS
  misbehaving (asking us to retire our only SCID with no
  replacement available), not us.

* QuicConnectionWriter.drainOutbound: skip the
  `streamsView.filter { !it.isClosed }` allocation when no streams
  are closed. Quick `any` scan first; only allocate the filtered
  list when at least one stream is actually closed. Saves an
  N-sized ArrayList per drain in the common case (~50 drains/sec
  × N up to ~2000 streams under multiplex load).

All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 40s.

https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
2026-05-09 00:14:07 +00:00
Claude 5421b81569 perf(quic): QPACK Huffman decode — no boxing, IntArray + binary search
Pre-fix QpackHuffman.decode allocated two boxed Integers per output
character: one for the `candidate` Int passed into
`HashMap<Int, Int>.get` and one for the wrapper-Integer return value.
Output also went through `ArrayList<Byte>`, boxing every emitted
byte as a `java.lang.Byte` (~16 bytes per output byte on a 64-bit
JVM). On a typical HTTP/3 response with ~30 header values, that's
hundreds of throwaway wrapper objects per request — pure GC churn
on the hot path.

The new layout keeps two parallel `IntArray`s per code-length:
codes[len] (sorted ascending) and syms[len] (the matching symbol
indices). Lookup is a primitive `IntArray.binarySearch(candidate)`
— no boxing, the array stays in JIT-friendly contiguous memory,
and the per-length arrays are tiny (a few entries each, since the
Huffman table is sparse at any given length).

Output uses a growable `ByteArray` with a manual position index
rather than `ArrayList<Byte>`. Pre-grow to 2× input size as a
rough upper bound — ASCII headers compress to ~62% with HPACK
Huffman, so we rarely need to grow.

Also fixes a latent bug: the new init loop covers lengths 5..30
(previously 5..29), restoring decoding for symbols 10 (LF), 13
(CR), 22 (DC2) which all use 30-bit codes per RFC 7541 Appendix B.
Pre-rewrite the HashMap path included these via `for (sym in 0..255)`
walking the full symbol table; the IntArray rewrite needed an
explicit length range and accidentally cut at 29. Added a unit
test exercising hand-encoded length-30 inputs to lock the fix in.

Behavior verified against RFC 7541 Appendix C test vectors and the
new length-30 round-trip. All 269 :quic:jvmTest tests pass.

https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
2026-05-09 00:03:39 +00:00
Claude ce8d852518 fix(quic): final stabilization sweep — CID picking, atomic gates, defensive checks
Round 5 of the audit follow-ups. The remaining low-leverage items
from the original audit all addressed in one pass.

* PathValidator: pick the smallest spare CID sequence number rather
  than LinkedHashMap insertion order. RFC 9000 §19.15 lets the peer
  issue NEW_CONNECTION_ID out of sequence (e.g. retransmits arriving
  after newer offers); insertion-order picking would land on
  whichever offer arrived first instead of the lowest seq, drifting
  away from the RFC-expected ordering. forceRotateToHigherSequence
  also now filters >= retirePriorToWatermark explicitly so we never
  pick a sequence below the watermark even if the pool somehow holds
  one.
* QuicWebTransportSessionState.close: driver.wakeup() AFTER enqueuing
  the WT_CLOSE_SESSION capsule + FIN but BEFORE driver.close, so the
  capsule actually reaches the wire instead of being short-circuited
  by the driver shutdown. Pre-fix the peer saw an abrupt UDP-level
  tear-down with no application-error-code surfaced.
* QuicStream.resetStream / stopSending: synchronized(this) atomic
  CAS for the "first call wins" gate. Pre-fix two concurrent callers
  could both observe `resetState == null` and both write — the
  second caller's errorCode would clobber the first while
  resetEmitPending was already set, so the writer emitted the
  RESET_STREAM with whichever value landed last.
* Http3Settings.decodeBody: per-id value range checks. A peer that
  advertises e.g. MAX_FIELD_SECTION_SIZE = 2^60 could otherwise
  drive our encoder into unbounded heap. Bounds chosen above any
  legitimate value (1 GiB for table-capacity / field-section caps,
  1 for boolean flags) and below 2^32 for unknown ids.
* Privatize crypto-relevant static byte arrays:
  InitialSecrets.V1_INITIAL_SALT, RetryPacket.V1_RETRY_KEY,
  RetryPacket.V1_RETRY_NONCE. Pre-fix these were public mutable
  ByteArrays — any caller could stomp on them, and any toString /
  reflection would leak the bytes. Crypto material doesn't need to
  be reachable outside the deriving / sealing path.
* peekHeader length cast: re-verified as already safe (lengthRaw is
  bounded by r.remaining before .toInt() — Int.MAX_VALUE ceiling
  enforced).

All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 2m 17s.

https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
2026-05-08 23:57:34 +00:00
Claude e4d96421c2 fix(quic): reserved-bit checks, TLS bounds, SendBuffer shrink
Round 4 of the audit follow-ups.

* Reserved-bit enforcement on unmasked QUIC headers (RFC 9000 §17.2 /
  §17.3.1). Pre-fix the parser silently accepted long-header packets
  with bits 0x0C set or short-header packets with bits 0x18 set after
  HP unmasking — the spec mandates PROTOCOL_VIOLATION close. Added
  [QuicProtocolViolationException] and a top-level catch in
  feedDatagram that translates the throw into markClosedExternally.
  Long-header parse also drops a now-dead `if form==1` branch on the
  first-byte mask: we already early-returned in non-long paths above,
  so the mask is always 0x0F.

* TLS handshake-message bounds:
   - TlsCertificateChain.decodeBody: reject `listLen > r.remaining`
     up front; assert `r.position == end` after the cert loop.
     Without this, a malicious peer could push us into reading past
     the message limit on per-cert extensions.
   - TlsServerHello.decodeBody / TlsEncryptedExtensions.decodeBody:
     reject trailing bytes after the extensions block.
   - TlsEncryptedExtensions.alpn: enforce RFC 7301 §3.1 (server
     returns EXACTLY one protocol_name); validate outerLen matches
     remaining and reject multi-name responses.

* SendBuffer.data shrink: pre-fix the doubling-on-grow buffer never
  shrank, so a stream that ever held N bytes pinned `data.size = N`
  for the connection's lifetime. Long-tail memory retention on
  per-stream basis. advanceFlushedFloorIfPossible now releases
  capacity once live bytes occupy ≤ 1/4 of the allocation, shrinking
  to max(SHRINK_FLOOR_BYTES=4096, 2*dataLen). Below the floor the
  doubling cost is negligible; above it the multi-MiB transients
  release back to the heap.

All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 47s.

https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
2026-05-08 23:40:20 +00:00
Claude 392df0384b fix(quic): HTTP/3 stream-context validation + ReceiveBuffer perf cliff
Round 3 of the audit follow-ups.

* Http3FrameReader gains a [StreamContext] parameter that enforces
  RFC 9114 §7.2 per-stream rules:
   - CONTROL: first frame MUST be SETTINGS (else H3_MISSING_SETTINGS);
     duplicate SETTINGS, DATA, HEADERS, PUSH_PROMISE all forbidden.
   - REQUEST: SETTINGS / GOAWAY / MAX_PUSH_ID / CANCEL_PUSH forbidden.
   - PUSH: similar set including PUSH_PROMISE.
   - Reserved types 0x02 / 0x06 / 0x08 / 0x09 explicitly rejected.
   - WT_BIDI_DATA / WT_UNI_DATA: reader is the wrong tool, throw.
   - UNCHECKED preserves prior test behaviour and is the default.
  WtPeerStreamDemux's CONTROL drain now constructs the reader with
  StreamContext.CONTROL, so a buggy server can no longer slip a DATA
  frame into our SETTINGS expectations and silently confuse the
  parser. The validation throws QuicCodecException, which the
  drainControlStream catch records on a new peerH3ProtocolError
  field — the QUIC layer / application reads it to close with the
  proper diagnostic instead of having the route() catch swallow it.

* ReceiveBuffer no longer coalesces overlapping segments on insert.
  Pre-fix every reorder fill allocated a fresh merged ByteArray of
  size (hi - lo) and copyInto'd each existing segment — under a 200-
  chunk reorder burst that was O(N²) bytes. The new layout keeps
  segments as a sorted, non-overlapping list (binary-searched on
  insert) and only allocates at readContiguous time, where it walks
  consecutive segments and concats them in a single pass. Adjacent
  segments are not eagerly merged — the read-side concat is bounded
  by the contiguous prefix the consumer is about to drain anyway.
  bufferedAhead becomes O(1) (cached counter) instead of O(N) sum.

* New tests cover the per-context rejection paths (CONTROL-stream
  first-frame check, DATA-on-control, SETTINGS-on-request, all four
  reserved frame types).

All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL.

https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
2026-05-08 23:30:17 +00:00
Claude 5c534b1774 fix(quic): bound peer-controlled buffers and channels — DoS hardening
Round 2 of the audit follow-ups. Each item caps a peer-controlled
allocation that pre-fix could be inflated to hold gigabytes of heap
or pin a CPU core indefinitely.

* Http3FrameReader: cap pending unparsed buffer (1 MiB) and per-frame
  body length (16 MiB). A peer streaming a partial-frame prefix
  without ever delivering the body now raises QuicCodecException
  instead of growing buf indefinitely.
* CapsuleReader: cap pending buffer (1 MiB) and per-capsule body
  (64 KiB). Symmetric encoder-side check on WT_CLOSE_SESSION reason
  size, matching the existing decoder cap.
* WtPeerStreamDemux: replace Channel.UNLIMITED with bounded channels
  + suspending sends. readyStreams now caps queued peer-initiated
  streams at 1024; per-stream chunkChannel caps at 64 chunks. The
  collector's suspending send naturally back-pressures via QUIC flow
  control when the application is slow, rather than pinning heap.
* WtDatagram.decode: validate quarter-id is in [0, (2^62-1)/4] so
  `r.value * 4` cannot overflow Long and wrap into a small signed
  value matching our session id (cross-session datagram injection).
* QuicReader.readBytes / skip: translate negative-count into typed
  QuicCodecException instead of letting IllegalArgumentException
  escape from copyOfRange.
* AckTracker: cap stored disjoint ranges at 64. A peer that sends
  alternating-bit-pattern PNs can no longer grow our ACK frame past
  what fits in a packet; oldest range evicts on overflow.
* JcaAesGcmAead: track recent encrypt nonces (8) instead of just the
  most-recent, so a single intermediate seal between two rebuilds
  can't mask a duplicate against the second-most-recent. Drop the
  remembered nonce on doFinal failure so a retry takes the safe
  fresh-Cipher path. Add synchronized() defence-in-depth.

Each cap has a generous default (above any legitimate use) but
finite. Tests use no-arg construction; existing call sites unaffected.

All 269 :quic:jvmTest tests pass.

https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
2026-05-08 23:12:37 +00:00
Claude b25644bf8b fix(quic): stabilization pass — concurrency, RFC compliance, DoS hardening
Verified and applied 12 focused fixes from a four-agent review of the
quic module. Each fix verified against the actual code; agent
findings that traced to false positives (pendingPing clear-without-emit,
sentPackets on encrypt failure) are documented in the review thread
but not changed.

Concurrency / flakiness:
- PTO consecutive count: double-increment removed; now incremented
  exactly once per PTO event in handlePtoFired before requeue, so the
  threshold check inside requeueInflightForProbe sees the post-
  increment value AND the between-probe re-requeue doesn't bump again.
- Wallclock → monotonic: QuicConnection.nowMillis defaults to
  TimeSource.Monotonic anchored at construction, so NTP step /
  suspend-resume can't poison RTT samples. Driver uses
  connection.nowMillis instead of carrying its own clock.
- Close-state race: atomic CAS via closeStateMonitor in close() and
  markClosedExternally so concurrent teardown paths can't both fire
  qlog "connection closed" and stomp on closeReason.
- streamsList CME: converted to @Volatile var List<QuicStream> with
  immutable-snapshot publishing under streamsLock. closeAllSignals'
  iteration is now CME-free without holding the (suspending) lock.
- JcaAesGcmAead: synchronized seal/open; multi-entry recent-nonce
  history (was single most-recent — could mask a duplicate against
  the second-most-recent under intermediate seals); on seal failure
  evict the cached nonce so a retry with the same nonce takes the
  safe fresh-Cipher path.

Wire correctness / spec:
- Connection-level send credit no longer debited on retransmits
  (added Chunk.isRetransmit; writer skips sendConnectionFlowConsumed
  += data.size when set). Pre-fix a few PTO rounds on a long stream
  exhausted credit and stalled the connection.
- ACK-delay shl overflow: clamp ackDelayExponent to 0..20 and
  clamp the peer's varint to (Long.MAX_VALUE >>> exponent) before
  shift; clamp negative now-vs-recv-time before shift on outbound
  AckTracker.
- Key-update commit only when new-phase packet PN exceeds
  largestReceived (RFC 9001 §6.1).
- RESET_STREAM final-size validation: enforce equality with prior
  FIN size and ≥ highestObservedOffset; close FINAL_SIZE_ERROR
  otherwise.
- STOP_SENDING handling: respond with RESET_STREAM on the local
  send side (was silently dropped, peer's flow-credit wasted).
- ReceiveBuffer.insert: typed InsertResult; second FIN with
  conflicting size and offset-past-FIN data both surface
  FINAL_SIZE_ERROR via the parser instead of being silently dropped.
- Retry SCID==DCID self-loop: reject Retry where the peer's SCID
  equals our original DCID (RFC 9000 §17.2.5.2).

DoS hardening:
- ACK PN walk: drainAckedSentPackets rewritten to scan in-flight
  keys against parsed ranges instead of walking every PN. A peer
  with firstAckRange = 2^62-1 used to pin a core forever; now
  bounded by the sent-packets map size.
- UDP socket: channel.connect(remote) after bind so the kernel
  filters off-path datagrams. Stops trivial source spoofing from
  burning AEAD attempts.

https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
2026-05-08 22:56:05 +00:00
Vitor Pamplona fbadc61070 Merge pull request #2796 from vitorpamplona/claude/review-ots-blockchain-deps-bKns7
Plan: Local Bitcoin headers explorer for NIP-03 OTS verification
2026-05-08 17:33:45 -04:00
Vitor Pamplona faf04fa1e3 update dependencies 2026-05-08 17:06:28 -04:00
Vitor Pamplona 0c4bf031f1 fix(quic): RFC 9002 §6.2.4 — emit two ack-eliciting packets per PTO probe
Single-packet probes need 6 PTO doublings (~19s) to land one datagram
through the `amplificationlimit` interop scenario's 6-drop window.
quic-go and msquic kill the connection at ~10s of silence regardless
of our handshake-timeout budget, so we never recovered against them
(diagnosed in the parent investigation; the 10s→30s timeout bump in
0a892b0d4b only fixed picoquic).

RFC 9002 §6.2.4 allows up to 2 ack-eliciting packets per PTO. Adding
the second probe halves recovery to ~3 PTO rounds (~5s) and lands
within strict server tolerances.

Wiring:
- New `QuicConnection.pendingProbePackets`, set to 2 by handlePtoFired.
- Extracted `requeueInflightForProbe` from handlePtoFired so the send
  loop can re-requeue inflight CRYPTO / STREAM bytes between probes.
- Send loop decrements the budget after each probe-bearing send; if
  the budget is still positive, re-requeues AND re-arms `pendingPing`
  so the no-data fallback (post-handshake idle) still emits a second
  PING. Without the `pendingPing` re-arm, only the first probe fires
  when CRYPTO is fully ACK'd — `pendingPing` is one-shot in
  collectHandshakeLevelFrames.

Verified end-to-end:
- amplificationlimit: ✕→✓ vs quic-go (35s→7s); ✓ no-regression vs
  picoquic (19s→7s) and quinn (15s→7s); msquic now reports server-
  side UNSUPPORTED (was failing). Recovery times across the board
  drop ~3x because handshake-loss recovery is ~3 PTO rounds instead
  of ~6.
- handshake / transfer / multiplexing / handshakeloss all green vs
  quic-go, quinn, picoquic, msquic — no regression on the core matrix.

Tests:
- New `ptoEmitsTwoProbePacketsPerRfc9002` in PtoCryptoRetransmitTest
  invokes the EXACT helpers the send loop uses (handlePtoFired then
  requeueInflightForProbe between drains) and asserts two distinct
  Initial datagrams with the same CRYPTO bytes at offset 0 on
  distinct PNs. Verified the test fails when budget is reverted to 1.
- Existing PTO + recovery tests stay green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:06:28 -04:00
Vitor Pamplona 7ac70498ac fix(quic-interop): bump HANDSHAKE_TIMEOUT_SEC 10s → 30s for amplificationlimit
The amplificationlimit testcase scenario drops client→server packets
2–7. Recovering past 6 consecutive drops via RFC 9000 PTO doublings
(0.3+0.6+1.2+2.4+4.8+9.6 ≈ 19s) is more than the previous 10s
budget allowed — we declared handshake_failed mid-recovery. Bumping
to 30s matches the multiconnect handshake budget and gives clean
PTO headroom.

Fixes: amplificationlimit ✕→✓ vs picoquic. No regression vs quinn
(was already passing at ~14s). Normal handshakes complete in <1s
so the bump is invisible outside lossy paths.

Still fails: amplificationlimit vs quic-go and msquic. Their
server-side handshake-progress watchdog gives up at ~10s of silence
regardless of our budget. The proper fix is RFC 9002 §6.2.4 — send
2 ack-eliciting packets per PTO probe instead of 1, halving the
recovery time for consecutive drops. That's a writer-side change,
deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:06:28 -04:00
David Kaspar 17a040d7ce Merge pull request #2794 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-08 22:57:28 +02:00
Crowdin Bot a4981075c1 New Crowdin translations by GitHub Action 2026-05-08 20:39:42 +00:00
Vitor Pamplona 2b4b978d0e Merge pull request #2795 from vitorpamplona/claude/fix-deprecated-mutex-lock-bRzWb
Rename lock fields to reflect their specific purposes
2026-05-08 16:38:03 -04:00
Claude 9ad4dbc356 fix(quic): replace deprecated lock with split locks in QlogObserverTest
The post-handshake status check uses lifecycleLock (status is guarded
by lifecycleLock per the lock-split refactor); the malformed-datagram
test uses streamsLock since feedDatagram requires streamsLock.
2026-05-08 20:21:26 +00:00
Vitor Pamplona 6854a528d0 Merge pull request #2793 from vitorpamplona/claude/quic-path-validation-dcid-g4Bpj
Implement RFC 9000 §9 client-initiated path validation and DCID rotation
2026-05-08 16:00:26 -04:00
Claude 07ba23a71c fix(quic): second-pass audit fixes for path validation
Three correctness bugs surfaced by post-fix re-audit, plus minor
cleanups.

  - Bug A (validation hang): checkPathValidationTimeoutLocked was
    only called from handlePtoFired. PATH_CHALLENGE is ack-eliciting
    so the peer ACKs it; that ACK resets consecutivePtoCount, which
    means the PTO timer that used to host the budget check stops
    firing. Validation could hang indefinitely on a peer that ACKs
    but doesn't reply with PATH_RESPONSE. Fix: drive the budget
    check from drainOutbound (every send-loop wake).

  - Bug B (stale retire): under abrupt-migration semantics the
    prior CID is abandoned the moment we rotate. Queuing the retire
    only inside applyPathResponse meant two consecutive failed
    validations would leave the original seq=0 unretired forever.
    Fix: queue priorSeq in tryStartValidation; advance
    activeCidSequence at trigger time so it tracks the on-wire DCID.

  - Bug C (spec MUST violation): RFC 9000 §5.1.2 requires server-
    forced retirement of the active CID when the peer's
    retire_prior_to advances past it. Previously the parser silently
    accepted the offer and we kept stamping a now-retired CID.
    Fix: new PathValidator.forceRotateToHigherSequence; called from
    applyPeerNewConnectionIdLocked after a successful Stored result.
    No PATH_CHALLENGE needed (same path, just different CID).
    Closes connection with CONNECTION_ID_LIMIT_ERROR if the pool
    is empty when forced rotation is needed.

Concurrency:
  - Add @Volatile to consecutivePtoCount. The driver kdoc claimed
    it already was; it wasn't. The send-loop reads it lockless for
    backoff calculation while three writers mutate it (driver PTO
    fire, parser ACK reset, applyPeerPathResponseLocked reset).

Cleanup:
  - Drop redundant destinationConnectionId re-stamp in
    applyPeerPathResponseLocked (already rotated at challenge time).
  - Fix PathMigrationResult kdoc to acknowledge that NotConnected
    is produced only by the connection-level wrapper.
  - Update applyPeerNewConnectionIdLocked kdoc with the §5.1.2
    forced-rotation contract.

Tests:
  - PathValidatorTest:
    + triggerRetiresPriorSequenceImmediately (Bug B)
    + twoConsecutiveFailedValidationsRetireAllAbandonedSequences
      (Bug B regression — would have caught the original miss)
    + forceRotateRunsWhenWatermarkPassesActiveCid (Bug C)
    + forceRotateNoOpWhenWatermarkBelowActive (Bug C edge)
    + forceRotateRotatesAgainWhenNewerOfferAdvancesWatermark (Bug C cascading)
  - ClientPathMigrationTest:
    + newConnectionIdWithRetirePriorToPastActiveForcesRotationOnSamePath
      (Bug C wire-level)
    + Updated fullMigrationRoundTrip to assert RETIRE rides in the
      same packet as PATH_CHALLENGE under abrupt-migration semantics.
    + Updated pathResponseWithMismatchingPayloadKeepsValidatingAndDcid
      to reflect activeCidSequence advances at trigger time.

All :quic:jvmTest (39 tests in path-validation suite) and
:nestsClient:jvmTest pass.

https://claude.ai/code/session_01PVVhSQXvw4K4oQ46FzpgaT
2026-05-08 19:57:55 +00:00
Claude 9b9ede2e1e fix(quic): audit fixes for client path validation + DCID rotation
Addresses seven bugs surfaced by post-landing audit of the path
validation feature.

Spec fixes (RFC 9000 §9):
  - Bug 1: PATH_CHALLENGE was going out on the OLD DCID because the
    writer reads conn.destinationConnectionId per packet and the
    rotation only happened on PATH_RESPONSE arrival. Now rotate the
    DCID inside triggerPathMigrationLocked (abrupt-migration model
    appropriate for the "old path looks dead" trigger condition).
    Fixes the headline feature — without this the challenge cannot
    actually exercise the new path.
  - Bug 2: 3 * PTO timeout dropped the failed CID without queuing a
    RETIRE_CONNECTION_ID. The peer kept the routing entry forever.
    checkValidationTimeout now queues the failed sequence per §5.1.2.
  - Bug 3: RETIRE_CONNECTION_ID for seq 0 was silently honored. We
    have no replacement SCID to give the peer (we don't issue our
    own NEW_CONNECTION_ID frames), so the connection is unusable.
    Close with INTERNAL_ERROR instead.
  - Bug 4: triggerPathMigration had no handshake-confirmed gate;
    §9.1 forbids migration before handshake confirmation. Returns
    new PathMigrationResult.NotConnected when status != CONNECTED.

Implementation fixes:
  - Bug 5: driver was calling Clock.System.now() directly instead
    of conn.nowMillis(), breaking virtual-clock tests.
  - Bug 6: PTO threshold check ran BEFORE the consecutive-PTO
    counter increment, so threshold=2 actually required 3 PTOs.
    Increment first; threshold semantics now match the constant.
  - Bug 7: applyPeerPathResponseLocked didn't reset
    consecutivePtoCount on successful validation; the next sleep
    inherited a stale exponential-backoff multiplier even though
    the peer just proved liveness.

Code quality:
  - Rename ValidationOutcome.Validated.newConnectionIdBytes →
    connectionId; PathValidationState.Validating.newCidBytes →
    newConnectionId. The "Bytes" suffix was redundant.
  - Drop unused PathValidator(initialActiveCidSequence) parameter.
  - Drop dead coerceAtLeast(2) in pool size calculation.
  - Make pendingChallenges and pendingRetireSequences internal.
  - Fix stale KDoc references (activatePendingValidatedCid,
    forceRetireActiveIfNeeded, "retirePriorTo decreased" — none
    survived the §19.15 clamp fix).
  - PathValidator.RecordResult: drop RetirePriorToRegressed
    enum value (clamped, never returned).
  - Surface qlogObserver.onConnectionIdRetired in both the
    success and timeout paths.

Tests:
  - ClientPathMigrationTest: existing fullMigrationRoundTrip
    test now asserts DCID rotates AT challenge time, not on
    PATH_RESPONSE.
  - New retireConnectionIdForSequenceZeroClosesConnection.
  - New pathResponseSuccessResetsConsecutivePtoCount.
  - New triggerPathMigrationBeforeHandshakeReturnsNotConnected.
  - PathValidatorTest:
    validationTimeoutAfter3PtoTransitionsToFailedAndRetiresFailedCid
    now asserts the failed sequence is queued for retire.
  - retirePriorToRegressionIsRejected → renamed to
    retirePriorToRegressionIsClampedNotRejected.

All :quic:jvmTest and :nestsClient:jvmTest pass.

https://claude.ai/code/session_01PVVhSQXvw4K4oQ46FzpgaT
2026-05-08 19:37:33 +00:00
Claude 667d3d51c7 docs(quartz): add parked plan for local Bitcoin headers OTS explorer
Comprehensive design doc for a headers-only Bitcoin P2P client that would
let NIP-03 OTS attestations be verified locally against the proof-of-work
chain instead of a trusted block explorer. Parked pending direction on
NIP-BC onchain-zaps verification, which has overlapping requirements.
2026-05-08 19:37:30 +00:00
Claude 435c49bae9 feat(quic): client-initiated path validation + DCID rotation (RFC 9000 §9)
Implements the client side of connection migration so a path that
stops receiving ACKs (NAT rebind, route flap, dead peer) can be
recovered without a fresh handshake:

  1. NEW_CONNECTION_ID frames from the server are stored in a
     PathValidator pool (was: parsed and dropped).
  2. After PATH_PROBE_PTO_THRESHOLD consecutive PTOs, the driver
     calls triggerPathMigrationLocked(); the validator picks an
     unused CID and queues a PATH_CHALLENGE with a CSPRNG payload.
  3. The writer drains the challenge into the next outbound 1-RTT
     packet using the new DCID; a RecoveryToken.PathChallenge is
     attached so loss recovery can re-queue on packet drop.
  4. Inbound PATH_RESPONSE that byte-equals the outstanding payload
     promotes destinationConnectionId to the new bytes and queues
     RETIRE_CONNECTION_ID for the prior sequence.
  5. RFC 9000 §8.2.4: validation is abandoned after 3 * PTO;
     timeout transitions to PathValidationState.Failed for retry.

Spec coverage:
  - §5.1.1 initial DCID is sequence 0
  - §5.1.2 retire_prior_to enforcement (clamping per §19.15
    reordering rule, force-retire of cached entries below
    watermark)
  - §8.2.2 byte-equal payload match
  - §8.2.4 3 * PTO abandonment
  - §19.15 frame-encoding error checks (retire_prior_to >
    sequence_number, invalid CID/token length)
  - §19.16 RETIRE_CONNECTION_ID frame codec + protocol-violation
    close on retire of an unissued sequence

Observability: QlogObserver gains onPathValidationStarted /
Succeeded / Failed and onConnectionIdActivated / Retired hooks
for qvis sequence diagrams.

Tests: PathValidatorTest (state-machine unit) +
ClientPathMigrationTest (full round-trip through InMemoryQuicPipe:
NEW_CONNECTION_ID -> trigger -> PATH_CHALLENGE -> PATH_RESPONSE ->
DCID rotated + RETIRE_CONNECTION_ID emitted). Existing
PathValidationTest (peer-initiated PATH_CHALLENGE echo) continues
to pass unchanged.

https://claude.ai/code/session_01PVVhSQXvw4K4oQ46FzpgaT
2026-05-08 18:51:34 +00:00
Vitor Pamplona d950be6d73 Merge pull request #2792 from davotoula/fix/imeta-dangling-space
fix(quartz): drop empty/whitespace-only imeta values
2026-05-08 14:05:53 -04:00
davotoula c7ff30a5d5 fix(quartz): drop empty/whitespace-only imeta values
NIP-92 imeta tag entries are encoded as "key SPACE value" strings.
When IMetaTagBuilder.add() received an empty (or whitespace-only)
value the encoder produced "key " with a trailing space, which
schema-validating relays reject as a malformed tag value.
2026-05-08 19:28:11 +02:00
Vitor Pamplona 48b48a8481 fix(quic-interop): summarize-matrix picks newest run dir, not sibling .stdout.log
`ls -1dt run-*` returns both the per-run directories AND the
`.stdout.log` files run-matrix.sh tees alongside them, interleaved by
mtime. `head -n 1` could land on a `.stdout.log` regular file, after
which the rest of the script trying to walk subdirectories under it
silently produced empty output ("no <pair> dir under …"). Walk the
listing instead and pick the first entry that is actually a directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:04:28 -04:00
Vitor Pamplona 5e3e5cc5a0 chore(quic-interop): auto-patch upstream certs.sh for macOS BSD tr
`LC_CTYPE=C tr -dc '[:alnum:]' </dev/urandom` in the upstream runner's
certs.sh trips "tr: Illegal byte sequence" on macOS — LC_CTYPE alone
doesn't override the runtime locale chain. Only the amplificationlimit
testcase exercises this loop (chain length > 1 → fakedns SAN inflation),
but if it aborts the runner stops the whole matrix BEFORE any later
test in TESTCASES_QUIC runs: handshakeloss, transferloss,
handshakecorruption, transfercorruption, ipv6, v2, rebind-port,
rebind-addr, connectionmigration, and the goodput/crosstraffic
measurements all silently never execute. The post-mortem summary
shows the 12 testcases that ran before the abort and looks deceptively
complete.

Add an idempotent `sed` step to run-matrix.sh that rewrites the line
to `LC_ALL=C tr` on every invocation, plus a plan-file note so the
next person hitting the deceptive summary doesn't repeat the
diagnosis. The patch is a working-tree edit to ../quic-interop-runner/,
not a fork; re-applied on every clone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:04:28 -04:00
Vitor Pamplona 5b8bd021b0 fix(quic): exempt retired stream ids from client-initiated squatting guard
The audit-4 #5 guard ran before the existing phantom-stream check, so an
msquic-style aggressive STREAM retransmit on a stream we'd opened and
retired (peer's loss-detector refire racing our FIN-ACK) closed the
connection with STREAM_STATE_ERROR. Observed in the parallel `transfer`
interop test where retransmits on retired streams 0/4 truncated whichever
URL was still mid-receive (5 MB → 2.2 MB).

Add `!isStreamIdRetiredLocked` to the guard so legitimate retransmits
fall through to the existing silent-drop branch. Genuine squatting on
never-opened CLIENT_* ids still closes — the existing FrameRoutingTest
case stays green because id 0 is never put into the retired ring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:04:28 -04:00
Vitor Pamplona abd565a460 Merge pull request #1914 from mstrofnone/desktop-namecoin-port
Port Namecoin NIP-05 resolution to Desktop app
2026-05-08 08:55:09 -04:00
m 9d84d01569 style: spotlessApply formatting + fix icon imports for upstream MaterialSymbols 2026-05-08 22:38:39 +10:00
m 23a7ac4770 fix(namecoin): accept single-identity {nostr:{pubkey,relays}} on d/ names
ifa-0001 doesn't mandate that domain records use the nostr.names
sub-dictionary. Operators who own a name outright commonly publish:

    {"nostr": {"pubkey": "<hex>", "relays": ["wss://..."]}}

(the same shape id/ records use). Before this fix d/-namespace branch
only accepted {"nostr":"<hex>"} or {"nostr":{"names":{...}}},
so a record like d/mstrofnone with the single-identity object form
silently failed with NoNostrField even though id/mstrofnone resolved
fine.

Resolution rules:
  1. nostr.names wins for any sub-identity.
  2. Root lookups fall back to bare pubkey when names["_"] is absent.
  3. Non-root lookups against names-only or single-identity records
     do NOT silently use the bare pubkey.
2026-05-08 22:33:06 +10:00
m 5efead42fb fix(quartz/electrumx): parse NAME_FIRSTUPDATE outputs alongside NAME_UPDATE
Names whose latest on-chain transaction is still the initial registration
(OP_NAME_FIRSTUPDATE = OP_2 = 0x52) were silently dropped because the
parser only matched OP_NAME_UPDATE (OP_3 = 0x53). The scripthash index
returns the FIRSTUPDATE tx in that case, so resolution looked
'unreachable' even though every server answered.

Accept both opcodes when scanning vouts and when parsing the script.
FIRSTUPDATE pushes <name> <rand> <value>, so skip the extra <rand> push
before reading the value.
2026-05-08 22:33:06 +10:00
m 4ab9cae213 fix(desktop): provide LocalNamecoin{Preferences,Service} so search can resolve
The lazy-init declarations and CompositionLocalProvider entries were lost
during the rebase, so SearchScreen always saw null services and skipped
Namecoin resolution. Add them back inside the LoggedIn branch alongside
LocalTorState.
2026-05-08 22:33:06 +10:00
m 50d0e15fe2 fix(desktop): show Namecoin lookup results in search
Re-add the Namecoin results UI block that was lost during the rebase.
Renders Loading/Resolved/NotFound/Error states above bech32/people/note
results when the query is a Namecoin identifier (.bit, d/, id/).
2026-05-08 22:31:46 +10:00
M dca55ebcc4 fix: update renamed APIs (trustAllCerts→usePinnedTrustStore, IRequestListener→SubscriptionListener)
Adapt cherry-picked PR commits to current main where:
- ElectrumxServer.trustAllCerts was renamed to usePinnedTrustStore
- IRequestListener was renamed to SubscriptionListener
2026-05-08 22:31:33 +10:00
M 645bcf8f44 refactor: lazy-load Namecoin services on Desktop (not kept in memory from start)
Move Namecoin service initialization from App-level (eager, on every startup)
to inside the LoggedIn branch (only created when user logs in and screens
that use Namecoin are reachable). Matches the Android lazy pattern in AppModules.

Also deduplicate NamecoinSettings: Android module now uses a typealias to the
commons module version, matching the original PR intent.
2026-05-08 22:31:33 +10:00
M 5369694b4d refactor: move NamecoinSettings and NamecoinResolveState to commons module
Eliminates code duplication between Android and Desktop:

- Move NamecoinSettings to commons/model/nip05DnsIdentifiers/namecoin/
  (with @Serializable and @Stable annotations)
- Extract NamecoinResolveState sealed class to its own file in commons
- Move NamecoinSettingsTest to commons (shared by both platforms)
- Replace Android NamecoinSettings.kt with a typealias to commons
- Update all imports in Desktop and Android modules
- Remove debug println statements from ImportFollowListDialog and Main
2026-05-08 22:31:33 +10:00
M 0b636d62aa Fix follow list import: validate pubkey length + add crash monitoring
Root cause: decodePublicKeyAsHexOrNull() returns truncated hex for
non-bech32 input (its else branch runs Hex.decode on arbitrary strings).
The relay then rejects the filter with 'Invalid author length'.

Fixes:
- Check raw 64-char hex FIRST (before bech32 parsing)
- Only attempt bech32 decode for strings starting with npub1/nprofile1/nsec1
- Validate all resolved pubkeys are exactly 64 hex chars
- Show specific error messages per identifier type instead of generic fallback
- Improved debug logging with pubkey prefix for each resolution path

Crash monitoring:
- Added Thread.setDefaultUncaughtExceptionHandler in Main.kt
- Logs crashes to ~/.amethyst-desktop-crash.log with timestamp and full stack trace
- Also prints to stderr for Gradle console visibility
2026-05-08 22:31:33 +10:00
M 0bc1c2838d desktop: full relay-integrated Import Follow List dialog
Rewrites ImportFollowListDialog with complete relay integration:

- Resolves identifiers: npub, hex, NIP-05 HTTP (via OkHttp), Namecoin
- Fetches kind 3 (ContactListEvent) from relays via subscription
- Parses follow list (p-tags) into selectable entries
- Fetches kind 0 metadata for display names (best-effort)
- Shows preview with select all/deselect all + individual toggles
- Publishes new kind 3 via broadcastToAll with selected follows
- State machine: Idle → Resolving → Fetching → Loaded → Publishing → Done
- Proper cleanup: DisposableEffect unsubscribes on dismiss
- 15s timeout for kind 3, 20s timeout for metadata enrichment
- Updates Main.kt call site to pass relayManager, account, localCache
2026-05-08 22:31:33 +10:00
M 21133c34c0 Fix search: use resolveDetailed() for proper timeout handling
The search bar was using resolve() which lets
NamecoinLookupException.ServersUnreachable propagate as an exception,
causing 'servers unreachable' to appear immediately instead of waiting
for the actual lookup to complete.

Switch to resolveDetailed() which catches exceptions internally and
returns typed outcomes (Success/NameNotFound/NoNostrField/
ServersUnreachable/InvalidIdentifier/Timeout). The search screen now
shows Loading for the full duration of the attempt (up to 20s) and
only shows the error after all servers have actually been tried.
2026-05-08 22:31:12 +10:00
M a7c4842d60 Wire ImportFollowListDialog into File menu (⇧⌘I)
The dialog was created but never accessible from the UI. Now:
- Added 'Import Follow List…' menu item in File menu (Shift+Cmd+I / Shift+Ctrl+I)
- showImportFollowListDialog state threaded through App composable
- Dialog rendered alongside ComposeNoteDialog and AddColumnDialog
2026-05-08 22:30:55 +10:00
Vitor Pamplona ab09c520ce Merge pull request #2758 from mstrofnone/feat/nip9a-community-rules
feat(quartz): NIP-9A community rules parser + validator (kind:34551)
2026-05-08 08:30:50 -04:00
Vitor Pamplona ce42a36882 Merge pull request #2780 from mstrofnone/fix/desktop-sidebar-scrollable
fix(desktop): make the single-pane navigation rail scrollable
2026-05-08 08:30:01 -04:00
M 5602429f9b Port Namecoin NIP-05 resolution to Desktop app
Add censorship-resistant NIP-05 verification using the Namecoin blockchain
to the Desktop (JVM) app, porting functionality from PRs #1734, #1771,

New files:
- DesktopNamecoinNameService: app-level service wrapping the Quartz
  NamecoinNameResolver with caching, custom server support, and live
  state flows. Uses plain JVM sockets (no Tor support on Desktop yet).
- DesktopNamecoinPreferences: Java Preferences API-backed persistence
  for Namecoin settings (enabled toggle + custom ElectrumX servers).
- NamecoinSettings: Desktop copy of the settings data class (no Android
  dependencies).
- LocalNamecoin: CompositionLocals for threading Namecoin service/prefs
  through the compose tree.
- NamecoinSettingsSection: Compose Desktop UI for configuring ElectrumX
  servers — toggle, active server display with DEFAULT/CUSTOM badge,
  add/remove custom servers, reset to defaults. Uses onPreviewKeyEvent
  for Enter-to-submit instead of Android KeyboardActions.
- ImportFollowListDialog: Dialog for importing follow lists via npub,
  hex, NIP-05, or Namecoin identifiers. Resolves identifiers to pubkeys
  with Namecoin blockchain support.

Modified:
- Main.kt: Instantiate DesktopNamecoinPreferences and
  DesktopNamecoinNameService in App composable, provide via
  CompositionLocals, wire into RelaySettingsScreen.
- SearchScreen.kt: Detect Namecoin identifiers (.bit, d/, id/) in the
  search bar, resolve via DesktopNamecoinNameService with loading/error
  states, display resolved user above standard results. Uses
  LaunchedEffect with key-based cancellation for stale lookups.
- DeckColumnContainer.kt: Pass namecoinPreferences to RelaySettingsScreen
  from CompositionLocal.

Tests:
- DesktopNamecoinPreferencesTest: round-trip persistence, add/remove
  servers, enable/disable, reset, duplicate handling.
- NamecoinSettingsTest: server string parsing/formatting, round-trips,
  edge cases, toElectrumxServers conversion.

The Quartz KMP library (commonMain + jvmAndroid) already contains the
core Namecoin resolution code (ElectrumXClient, NamecoinNameResolver,
NamecoinLookupCache) shared across Android and Desktop.
2026-05-08 22:29:23 +10:00
m 01f6c1c24b fix(desktop): make the single-pane navigation rail scrollable
The left navigation rail in single-pane mode renders a fixed list of
pinned screens (Home, Reads, Notifications, ...) plus a 'More' launcher
and a stack of bottom controls (relay health, bunker heartbeat, tor
status, account switcher).

Material3 `NavigationRail` lays its children out in a non-scrollable
`Column`. When the window is short — either because the OS window is
small or the user pinned several screens — the bottom items in the
list (and the 'More' button) get clipped and become unreachable.

Replace the `NavigationRail` with a `Column` that mirrors the rail's
container styling and splits content into two regions:

- A scrollable region (weight(1f) + verticalScroll) holding the pinned
  screens and the 'More' launcher. Overflow now scrolls instead of
  clipping.
- A fixed bottom region holding the relay health indicator, bunker
  heartbeat, tor status indicator, and the account switcher. These
  remain anchored at the bottom of the rail.

Item visuals are preserved by keeping `NavigationRailItem` for the
items themselves, with `NavigationRailItemDefaults.colors()`.

No behavior change when the rail content already fits the window.
2026-05-08 22:28:17 +10:00
Vitor Pamplona 36112c8b11 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-05-08 08:25:49 -04:00
Vitor Pamplona 2097089d3d Merge pull request #2788 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-08 08:20:44 -04:00