Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resolve #6068 digestauth challenge redux #6138

Conversation

blast-hardcheese
Copy link
Contributor

@blast-hardcheese blast-hardcheese commented Mar 18, 2022

Resolves #6068

Attempting to address PR feedback in #6077

Refactoring NonceKeeper

  • Removing synchronized calls
  • Switching var to Ref
  • Making all exposed methods F[_]
  • Splitting out AuthStore trait with a more secure scheme that doesn't require the user storing the user's passwords in plaintext

Some remaining tasks:

  • Performance testing
  • Push F[_] further: remove remaining var, while/tailrec/return

Opening this draft early on to communicate intent and direction, as well to get feedback early

@mergify mergify bot added series/0.22 PRs targeting 0.22.x module:server labels Mar 18, 2022
@armanbilge
Copy link
Member

Took a quick pass through—excellent work, this is looking very nice 👍

@armanbilge
Copy link
Member

A couple other notes:

  1. Annoying, but I think we have to keep the old NonceKeeper as well, because we need it to implement the deprecated methods in DigestAuth that are not using F[_] to preserve backwards-compatibility. Just like you did in your other PR.
  2. Don't worry about performance testing, but it would be nice to clean up those remaining imperative stuff.

@blast-hardcheese blast-hardcheese force-pushed the resolve-6068-digestauth-challenge-redux branch 3 times, most recently from 9e7985c to c666a7f Compare March 19, 2022 22:01
@blast-hardcheese blast-hardcheese marked this pull request as ready for review March 19, 2022 22:21
@blast-hardcheese
Copy link
Contributor Author

blast-hardcheese commented Mar 19, 2022

Performance analysis, I ran this script against efadfc9 (origin/series/0.22) and c666a7f (this branch):

-BasicAuthentication should respond to a request with correct credentials: 15ms
+BasicAuthentication should respond to a request with correct credentials: 19ms
-BasicAuthentication should respond to a request with unknown username with 401: 4ms
+BasicAuthentication should respond to a request with unknown username with 401: 6ms
 BasicAuthentication should respond to a request with wrong password with 401: 1ms
-BasicAuthentication should respond to a request without authentication with 401: 20ms
+BasicAuthentication should respond to a request without authentication with 401: 24ms
-DigestAuthentication should avoid many concurrent replay attacks: 16ms
+DigestAuthentication should avoid many concurrent replay attacks: 31ms
-DigestAuthentication should respond to a request with correct credentials: 29ms
+DigestAuthentication should respond to a request with correct credentials: 40ms
-DigestAuthentication should respond to a request without authentication with 401: 8ms
+DigestAuthentication should respond to a request without authentication with 401: 49ms
-DigestAuthentication should respond to invalid requests with 401: 17ms
+DigestAuthentication should respond to invalid requests with 401: 7ms
-DigestAuthentication should respond to many concurrent requests while cleaning up nonces: 85ms
+DigestAuthentication should respond to many concurrent requests while cleaning up nonces: 140ms

(the script just runs server / testOnly org.http4s.server.middleware.authentication.AuthenticationSuite multiple times in a loop, then averages the resulting times)

I'm somewhat confused by the directive of "don't worry about performance", but I'm heartened by my largely amateurish attempt not having too negative of a performance impact. I'm available and interested in idioms or suggestions to improve performance, if necessary. mimaReportBinaryIssues reports success as well, which is also great.

Copy link
Member

@rossabaker rossabaker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You've been in good hands with others here, so I only skimmed it. Looking good, though. I imagine we'll do another round of releases this upcoming week, and I'd love to get this in.

On the subject of "don't worry about performance", I haven't followed the conversation closely enough to see where it came up. I'd say we do strive for performance, but correctness comes first, and we don't generally trade legibility or purity for performance without a profiled hotspot. Benchmarking and optimizing is fun, but when we're all mostly writing services that listen on a socket and then make a remote database query and then enrich that with a client call to another web service, an extra flatMap here or there is a drop in the ocean.

Copy link
Member

@armanbilge armanbilge left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ross beat my review by a few minutes, so apologies for the duplicated comments 😆

I made the comment about performance in #6138 (comment) (emphasis added):

Don't worry about performance testing

What I meant was, we wouldn't block your PR due to lack of benchmarks :) for sure, we don't want you to tank performance! Also, meaningful benchmarking is not easy: you have to account for JVM warm-up and JIT-optimizations, etc.

@blast-hardcheese
Copy link
Contributor Author

blast-hardcheese commented Mar 20, 2022

Thanks for the reviews, I've got my work cut out for me!

In taking the next step in verifying my work here, I ran across Requires that the server can recover the password in clear text, which is _strongly_ discouraged., which would give the impression that this whole class is not intended for even limited production use in real systems, as it's impossible to use DigestAuth without supplying an instance of that function, but the function itself is documented as being strongly discouraged.

In an attempt to resolve this, I've pushed up Flesh out AuthenticationStore, a bincompat change that promotes a type alias to a sealed trait, preserving the previous functionality but offering a more secure variant.

I'm offering this here as it's convenient to only break bincompat once, since we're already changing everything up, but this definitely falls under scope creep.

@blast-hardcheese
Copy link
Contributor Author

I made the comment about performance in #6138 (comment) (emphasis added):

Don't worry about performance testing

What I meant was, we wouldn't block your PR due to lack of benchmarks :) for sure, we don't want you to tank performance! Also, meaningful benchmarking is not easy: you have to account for JVM warm-up and JIT-optimizations, etc.

That makes more sense 😅

I figured enough runs of the auth suite would be sufficient ballpark, which is why I included them, since this is (from my vantage point) a significant enough change to warrant them, though Ross' point about an extra flatMap here or there was useful for keeping from spiraling off into the weeds.

I've updated the PR with feedback, and will update again shortly once I've finished this test for the Md5HashedAuthenticationStore.

blast-hardcheese and others added 13 commits March 20, 2022 20:18
Resolves http4s#6068

NonceKeeper is package-private, so there's no way to construct one,
meaning there's no way to call `challenge`.

Mirror the same parameters from `apply`, and maintain the previous
`challenge` method for bincompat.
…ion/DigestAuth.scala

Co-authored-by: Arman Bilge <armanbilge@gmail.com>
In order to achieve the goal of F.delay(new NonceKeeper) but maintaining
the original intent of `apply`, just revert everything in this PR, make
the original (unusable) `challenge` `private`, and create a new public
`challenge` with the necessary parameters, intended for human use,
without impacting the existing flow.
…ion/DigestAuth.scala

Co-authored-by: Arman Bilge <armanbilge@gmail.com>
Internals unchanged, in preparation for a pure rewrite of DigestAuth.

Includes a private bincompat method
Internals unchanged, in preparation for a pure rewrite of DigestAuth.

Includes a private bincompat method
@blast-hardcheese blast-hardcheese force-pushed the resolve-6068-digestauth-challenge-redux branch from ee3a040 to 3122229 Compare March 21, 2022 03:20
@blast-hardcheese blast-hardcheese force-pushed the resolve-6068-digestauth-challenge-redux branch from 160be26 to aa6ab18 Compare March 21, 2022 03:39
@blast-hardcheese
Copy link
Contributor Author

Alright, this is good to go, barring one final thought:

Without knowing how to precompute the ha1, I perceive this sort of like an open loop; we're expecting the users to just know how to do that, reimplementing a part of the spec in their own service.

I added a precomputeHash(username: String, realm: String, password: String): F[String] convenience function attached to the Md5HashedAuthStore companion object to resolve this usability gap, though I'm open to removing it if it's deemed unnecessary.

Copy link
Member

@rossabaker rossabaker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is shaping up well. I gave it a more thorough review. Lots of comments, but hopefully none of them stir the pot too much.

)
type AuthenticationStore[F[_], A] = String => F[Option[(A, String)]]

sealed trait AuthStore[F[_], A]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be sealed? I see we match on them in checkAuthParams. Would it be better if those cases were a method on this, to permit more pluggable implementations? Or are plaintext and MD5 the two that are specified?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on how I understand the spec, there are only two sensible implementations:

Plain-text passwords:

md5($un:$realm:$pw):$nonce:$nc:$cnonce:$qop:md5($method:$uri)

Pre-hashed passwords:

md5($un:$realm:$pw) -> ha1 in the db
                |
         retrieve from db
                v
               $ha1:$nonce:$nc:$cnonce:$qop:md5($method:$uri)

The way I see it, by pushing more into subclasses of AuthStore, we'd need to expose all these parameters for dubious benefit. ha1 is the only secret that needs to be exposed to user code

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing the spec again for another comment, this is actually called out explicitly by the spec:

Note that the HTTP server does not actually need to know the user's
cleartext password. As long as H(A1) is available to the server, the
validity of an Authorization header may be verified.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An optional header allows the server to specify the algorithm used to
create the checksum or digest. By default the MD5 algorithm is used
and that is the only algorithm described in this document.

It looks like it can be md5, md5-sess, or extensions that probably nobody understands. I think it would be unpleasant to register and understand more for little gain.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The need to add support for this stuff may motivate new contributors 😉

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Additionally, adding the required helper methods, similar to Md5HashedAuthStore.precomputeHash)

private def getRandomData[F[_]](bits: Int)(implicit F: Sync[F]): F[String] =
F.delay(new BigInteger(bits, random).toString(16))

def gen[F[_]: Sync](bits: Int): F[NonceF[F]] =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is going to need a Blocker and a ContextShift in the long run. This can merge before #6165, but it would be best to get that done before we have a release and need to do a MiMa exemption.

The nice thing is, in Cats-Effect 3, this becomes more transparent in the API.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sort of interesting -- NonceKeeperF[F].newNonce() already requires a permit for the singleton semaphore, so I think acquiring an instance of Blocker at that time makes sense. I can keep moving it further up if desired, I really don't have a good intuition around where that instance needs to come from.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling new BigInteger(bits, random) will block on some platforms as it gathers entropy. We want to shift that call specifically to another thread, so we're not blocking the compute pool on the IO to /dev/random. There's also a mutex to worry about on SecureRandom, but that should be fine since you're putting this instance behind a semaphore.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does Blocking[F].use actually acquire a new thread/threadpool? Should that parameter be bubbled all the way out to the caller's code?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cats-Effect 2's Blocker does require a thread pool. One Blocker should suffice for most apps, but any app that does blocking needs one. It's part of the runtime in Cats-Effect 3, and available from the Sync constraint, so this will melt away in 0.23.

Engineering tradeoff: if we don't thread it through, this could suck real bad for a small number of users. If we do thread it through, we're going to all that work for, like, Windows users, and other people who do Weird Shit. http4s-0.22 is basically EOL. Personally, I'm only fixing bugs on it. And this blocking is not new, and nobody has complained. So if you'd prefer, we could put a fat disclaimer on it and know it will be better in CE3. But this is "safer".

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I overlooked the Blocker.use. Yeah, that creates a new cached thread pool on every invocation. What we want is one cached blocker for the entire app that gets threaded through. This is where the API is going to get uglier in CE2.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swapped it out to a single allocated Blocker on init, backed by the global threadpool, as this is going away anyway.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't want to run blocking operations on the global threadpool. That pool has special magic if you additionally use a special standard library blocking incantation, which we aren't. Blocking should typically be done on a dedicated, cached thread pool. I think it would be better to remove it entirely if we're not going to thread it through.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright -- threaded it through, as this is a new integration, and the exposure of it will better match the functionality in CE3 for users through the upgrade process.

@blast-hardcheese blast-hardcheese force-pushed the resolve-6068-digestauth-challenge-redux branch from 74638e5 to f84f759 Compare March 21, 2022 19:24
@blast-hardcheese
Copy link
Contributor Author

Seems like an unrelated failure, I spent a few minutes trying to understand what's going on but I'm completely out of my depth.

While the failure message is

==> X org.http4s.DecodeSpec.decode should either succeed or raise a CharacterCodingException  0.083s munit.FailException: /Users/dstewart/Projects/deps/http4s/tests/src/test/scala/org/http4s/DecodeSpec.scala:134
133:
134:    test("decode should either succeed or raise a CharacterCodingException") {
135:      forAll { (bs: Array[Byte], cs: Charset) =>

Failing seed: ul4X_OcjscjNgHDzfcislTYeeRBho4GD7b-pOuxHoPJ=
You can reproduce this failure by adding the following override to your suite:

  override val scalaCheckInitialSeed = "ul4X_OcjscjNgHDzfcislTYeeRBho4GD7b-pOuxHoPJ="

Falsified after 91 passed tests.
> ARG_0: [B@7f12f19
> ARG_1: x-COMPOUND_TEXT
> ARG_0_ORIGINAL: [B@55d47e2
[error] Failed: Total 10, Failed 1, Errors 0, Passed 9
[error] Failed tests:
[error]         org.http4s.DecodeSpec
[error] (tests / Test / testOnly) sbt.TestsFailedException: Tests unsuccessful
[error] Total time: 8 s, completed Mar 21, 2022 2:31:32 PM

I actually found that val caused a NPE, but override def reproduces the error:

-  override val scalaCheckInitialSeed = "ul4X_OcjscjNgHDzfcislTYeeRBho4GD7b-pOuxHoPJ=";
+  override def scalaCheckInitialSeed = "ul4X_OcjscjNgHDzfcislTYeeRBho4GD7b-pOuxHoPJ=";

@rossabaker
Copy link
Member

Yeah, that test flake looks like another #4262. Don't worry about that here.

We're already behind a Semaphore, and this Blocker instance is just to satisfy http4s#6165.

Use the Blocker constructor available on both JVM and JS
Copy link
Member

@rossabaker rossabaker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your perseverance on this. I think it's in a good place.

@rossabaker
Copy link
Member

@armanbilge, are you good with this as is? You had opinions earlier.

Copy link
Member

@armanbilge armanbilge left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the scope of this PR got away from me 😆 really fantastic work by @blast-hardcheese. Both CE2 and the RFC are a bit out of my comfort zone.

I glanced through and have one question, but not a blocker.

private[authentication] class Nonce(val created: Date, var nc: Int, val data: String)

private[authentication] object Nonce {
val random = new SecureRandom()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When this becomes an effect, it can no longer be a global. So where will it be initialized?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The usage is deprecated. I don't think we need to make it an effect. Though we could maybe add a deprecation notice to this type, to assure it's only called through deprecated code.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, is this not going to use the stuff from #6165?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was planning on welding it together after this. @blast-hardcheese has suffered enough. But I could merge it into this just to see what it looks like.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, sounds good to me 🚀

@rossabaker rossabaker merged commit a7e2818 into http4s:series/0.22 Mar 24, 2022
@blast-hardcheese blast-hardcheese deleted the resolve-6068-digestauth-challenge-redux branch March 24, 2022 17:41
@armanbilge armanbilge mentioned this pull request Apr 30, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
module:server series/0.22 PRs targeting 0.22.x
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants