The Basic arm returned out of the challenge loop as soon as it found a
Basic challenge it had already tried. The handler is a network
interceptor, so Basic is always primed preemptively over HTTPS — the
abort therefore fired on the first 401, and a Digest challenge later in
the same header was never read. A Baikal or Apache front end offering
both and rejecting Basic at the app layer got no Digest answer at all,
and the account was marked as needing sign-in for good. Every challenge
is read before anything is decided now; giving up still happens, just
after the whole header has been looked at.
clientNonce and nonceCount sat on the companion object while SyncEngine
builds one handler per account, so two accounts syncing at once
interleaved their nc values. They are instance state now, and a new
server nonce restarts the count — RFC 7616 3.4.1 counts requests sent
with that nonce, starting at 1, and Apache's AuthDigestNcCheck answers
401 for a carried-over count.
qop values were split on "," and compared untrimmed, so
qop="auth, auth-int" quietly downgraded to auth and qop=" auth" matched
nothing — falling into the RFC 2069 branch, which emits no qop, nc or
cnonce and which an RFC 7616 server rejects outright.
Documented as changes 10-12 in PROVENANCE. The four static assignments
in upstream's digest tests now address the handler; nothing else in that
file changes.
The cleartext gate sits on the emission, but the 401 branch cached the
Basic challenge before reaching it. So after refusing a plain-HTTP
challenge the handler still believed Basic was in play: the preemptive
block is skipped because it requires both caches empty, the refusal
repeats, and the 401 after that logs "Basic credentials didn't work last
time" about a credential that never reached the wire.
insecurePreemptive gated only the preemptive branch, so a plain-HTTP
server answering 401 with a Basic challenge still got the password in a
header every hop can read. The flag's name was accurate; its coverage
was not. The gate moves onto the Basic emission, covering both paths,
and the flag becomes insecureBasic.
Digest is left answered over cleartext: it never puts the password on
the wire, and refusing it would break a LAN server the day the
per-account cleartext opt-in ships.
Not reachable in the app today -- network_security_config forbids
cleartext outright and nothing passes allowCleartext -- but :caldav is a
plain JVM module meant for reuse, where neither of those holds.
Upstream's testBasic asserted this exact behaviour over http://, so it
is amended to https:// and the cleartext cases are pinned explicitly.
PROVENANCE change 9 records it as the one upstream test this port
changes rather than inherits.
The auth handler decided which hosts may receive the password by
comparing the last two labels of their names. A server at
cloud.example.co.uk therefore scoped the app password to co.uk, one at
myhome.duckdns.org to duckdns.org, and a self-hoster at 192.168.1.10 to
"any address ending .1.10". The handler sends Basic preemptively, before
any challenge, so those hosts get the password unprompted on the first
HTTPS request.
It is reachable: ServiceDiscovery accepts an SRV target outside the
domain it queried, over plain UDP DNS. Scoped correctly, an on-path
attacker needs a certificate for a name inside the victim's own
registrable domain. Scoped to co.uk, they need one for a domain they
already own.
Now topPrivateDomain(), from the list OkHttp bundles, falling back to the
exact host where there is none -- null means no restriction here, so an
IP literal or localhost must not pass one through. The handler had to
change with the caller: it re-derives the domain per request, so fixing
only the caller withholds the credential from everything.
Still trusted: two hosts under one registrable domain share an owner.
That is what iCloud's caldav/pNN-caldav split needs, and narrowing
further costs it.
Everything here came from running the account flow against a real
Nextcloud rather than from reading the code.
Discovery
- A typed bare origin now gets the RFC 6764 well-known probe. It was
returned as the only candidate, so `https://cloud.example.com` — what
people actually type — was PROPFIND'd against the web UI, answered 405,
and a working Nextcloud reported as "not a CalDAV server".
- A same-host HTTPS→HTTP redirect is put back on TLS instead of refused
(`dav` change 7). A Nextcloud behind a TLS-terminating proxy without
`overwriteprotocol` builds every redirect with http://, including the
/.well-known/caldav hop discovery depends on. Cross-host still throws.
- Outcomes carry a `Cause` the UI translates, not the server's own words.
"HTTP 405 Method Not Allowed" told someone entering an address nothing,
in a language they may not read, from outside strings.xml.
- An IPv6 origin keeps its brackets: `HttpUrl.host` returns "fd00::1", so
the rebuilt origin did not parse and a homelab address came back as
"not an address".
Login Flow v2
- The poll response's scheme is coerced, never refused. Nextcloud returns
the app password exactly once, so throwing there burned a live
credential and left it dangling in the user's device list. The host
mismatch already worked this way; the scheme now matches it.
Accounts
- The accounts screen observes Room and the sign-in state instead of
taking a snapshot, so a sync landing — or a 401 stopping an account —
reaches a screen that is already open.
- A per-account detail screen, and provider identity (`CalDavProvider`)
shared with the quirk table so one list drives both the icon and the
warning.
- The password field masks: floret-kit's `InlineTextField` gained a
visual transformation, since `KeyboardType.Password` only tells the IME
to drop suggestions.
New :caldav module — MIT, plain JVM, api-depends on :dav. Separate from the
vendored MPL tree so the licences stay unmixed, and so "no Android types" is a
compile-time guarantee rather than a discipline. Chunk 2 split again: the
Android account layer (Keystore, AccountManager, Custom Tabs, account-add UI)
is 2c, with different verification and an on-device review.
- ServiceDiscovery: the RFC 6764 ladder. SRV priority/weight, TXT path=,
non-443 ports, "." targets, well-known then root.
- CollectionClassifier: the two filters that are inversions of the obvious
rule. An absent or empty supported-calendar-component-set means "supports
everything", and classification is a positive test for CALDAV:calendar on an
unordered set — excluding schedule-outbox would drop SOGo's main calendar.
- CalDavDiscovery: OPTIONS gate, principal, every home set, Depth-1 by name.
A failing home set does not fail the account, and every home set failing is
reported as an error rather than as an account with no lists.
- NextcloudLoginFlow: POST not GET, a User-Agent the user can recognise when
revoking, 404-means-pending only, both URLs origin-checked, host mismatch
carried rather than refused (reverse proxies are ordinary).
- PreemptiveBasicInterceptor, ServerQuirks.
dnsjava 3.6.3 (BSD-3) added: Android's DnsResolver is callback-only and cannot
do the TXT path lookup, and JNDI's DNS provider does not exist on Android.
Behind an interface, so every trap is tested with a fake and no network.
:dav gains change 6 — <D:unauthenticated/> is parsed rather than inferred from
a null href, which also fires on a merely non-conformant empty element.
52 tests here, 78 in :dav. SYNC.md's live-probed trap table is executable now.
JitPack-only publishing conflicts with FAIL_ON_PROJECT_REPOS, artifacts are
unsigned and not immutable, and upstream shipped two breaking majors 19 days
apart. 2.2.1 is the last OkHttp release: 3.0.0 moved to Ktor and 4.x needs
Java 21, and SYNC.md's whole auth section is written in OkHttp terms, so 4.x
would have invalidated it. A plain JVM module, not an Android library — the
tree has no Android imports and keeping it that way keeps the floret-kit
extraction a file move.
Four changes from upstream, all in dav/PROVENANCE.md:
- commons-lang3 dropped (one import, one call). The replacement requires the
whole string to be consumed, as DateUtils did: pattern 1 ends in a literal
'GMT', so SimpleDateFormat would match "...GMT+02:00" as a prefix and throw
the offset away.
- HTTP dates were parsed and formatted in the device's local zone — the GMT in
the format string is a quoted literal and timeZone was never set, so every
getlastmodified was out by the local UTC offset. Upstream tests dates
nowhere. The formatter is also no longer shared and mutable.
- dav4jvm#209: permanent redirects now reach the caller via
DavResource.permanentLocation, cleared per request so it never describes an
earlier one.
- xpp3 is compileOnly; Android supplies org.xmlpull.v1.
SYNC.md's other claimed defect, "does not follow 303", is not true of 2.2.1 —
pinned by a test rather than removed from the notes.
CI names :dav:test explicitly; testDebugUnitTest is Android-only and would
have run none of the vendored suite. lintDebug added to the per-chunk checks
after it caught a literal byte-order mark left by chunk 1.