-
Notifications
You must be signed in to change notification settings - Fork 46
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
feat: implement hawk authentication #20
feat: implement hawk authentication #20
Conversation
src/auth.rs
Outdated
&mut token_secret, | ||
)?; | ||
|
||
// * Use `token_secret` as the secret key for calculating the Hawk request MAC |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wasn't sure about the significance of calculating the Hawk request MAC
here. Does it mean something more than the direct comparison of mac
and token_secret
I'm doing below?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's more to it. The "id" field from the header and the "token_secret" from this calculation form a hawk "credential" which can be used for signing requests:
https://github.com/taskcluster/rust-hawk/blob/master/hawk/src/credentials.rs#L28
So you now need to take a hmac over the contents of the Authorization
header using token_secret
as the key, and compare that to the provided mac
field in the header.
I'm not sure what the abstractions are like in the rust hawk library. You may be able to do the validation by passing "token_secret" as the key into this validate_header
function:
https://github.com/taskcluster/rust-hawk/blob/master/hawk/src/request.rs#L131
Or maybe as the key for this lower-level Mac
impl:
https://github.com/taskcluster/rust-hawk/blob/master/hawk/src/mac.rs#L23
Either way, it should do the constant-time-compare part for you for free.
src/auth.rs
Outdated
// 'userid': 42, | ||
// 'expires': 1329875384.073159 | ||
// 'salt': '1c033f' | ||
// } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this a comprehensive definition or can other properties be present?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Other properties can and will be present but you're welcome to ignore them. The current set are at:
https://github.com/mozilla-services/tokenserver/blob/master/tokenserver/views.py#L403
Plus "salt" and "expires" which get added automagically.
You may find the "node" property useful as an extra sanity-check, it should match the hostname of the current machine.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I notice the linked code has a uid
property but your comment had userid
. I'm presuming that was just a typo in the comment, am changing to uid
here.
src/auth.rs
Outdated
|
||
// * Check that the "expires" timestamp is not in the past. | ||
let now = Utc::now().timestamp_millis(); | ||
if (payload.expires.round() as i64) < now { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Whoops, just realised that this is all kinds of wrong, I need to multiply so that I'm not comparing apples to oranges.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice work @philbooth! The logic here looks solid through until the end, where you need to do the extra hawk-mac-calculation step.
If it helps, I generated some test data using a local syncserver instance.
Master secret: "Ted Koppel is a robot"
(as ascii bytes)
Authorization header: Hawk nonce="uVkprSQ=", mac="ZZUcPtn7CU1kb2fclI4mkjjGOPEEKQf2NwWPA62hk7A=", id="eyJub2RlIjogImh0dHA6Ly9sb2NhbGhvc3Q6NTAwMCIsICJ1aWQiOiAxLCAiZXhwaXJlcyI6IDE1MzU1ODAyMDIsICJmeGFfdWlkIjogImYyNjMwNzZlZDhhNTcyMmE1YmE0YzJhMWQwN2IwZjMwIiwgInNhbHQiOiAiM2JkODk2IiwgImRldmljZV9pZCI6ICIxOTFiMjJiNjliY2JhZGZiYWE3NDYyZWYzOTQxMDI2NCJ9cUipB9hpg83VuYfwFk-S120QSp8mzrBROcq9vxzlha0=", ts="1535579912"
Validated token data: {'node': 'http://localhost:5000', 'uid': 1, 'expires': 1535580202, 'fxa_uid': 'f263076ed8a5722a5ba4c2a1d07b0f30', 'salt': '3bd896', 'device_id': '191b22b69bcbadfbaa7462ef39410264'}
src/auth.rs
Outdated
// 'userid': 42, | ||
// 'expires': 1329875384.073159 | ||
// 'salt': '1c033f' | ||
// } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Other properties can and will be present but you're welcome to ignore them. The current set are at:
https://github.com/mozilla-services/tokenserver/blob/master/tokenserver/views.py#L403
Plus "salt" and "expires" which get added automagically.
You may find the "node" property useful as an extra sanity-check, it should match the hostname of the current machine.
src/auth.rs
Outdated
let mut check: Hmac<Sha256> = Hmac::new_varkey(&signing_secret)?; | ||
check.input(&payload); | ||
let expected_signature = check.result().code(); | ||
if !constant_time_compare(signature, &expected_signature) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't implement this yourself; check.result()
has a method for doing the constant-time compare for you in a safe way, IIUC from the docs this should be something like: check.result().verify(&expected_signature)?
src/auth.rs
Outdated
&mut token_secret, | ||
)?; | ||
|
||
// * Use `token_secret` as the secret key for calculating the Hawk request MAC |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's more to it. The "id" field from the header and the "token_secret" from this calculation form a hawk "credential" which can be used for signing requests:
https://github.com/taskcluster/rust-hawk/blob/master/hawk/src/credentials.rs#L28
So you now need to take a hmac over the contents of the Authorization
header using token_secret
as the key, and compare that to the provided mac
field in the header.
I'm not sure what the abstractions are like in the rust hawk library. You may be able to do the validation by passing "token_secret" as the key into this validate_header
function:
https://github.com/taskcluster/rust-hawk/blob/master/hawk/src/request.rs#L131
Or maybe as the key for this lower-level Mac
impl:
https://github.com/taskcluster/rust-hawk/blob/master/hawk/src/mac.rs#L23
Either way, it should do the constant-time-compare part for you for free.
src/auth.rs
Outdated
} | ||
} | ||
|
||
fn constant_time_compare(actual: &[u8], expected: &[u8]) -> bool { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You shouldn't need to do this yourself, but there's also a create for it btw ;-)
src/auth.rs
Outdated
type Result = AuthResult<HawkPayload>; | ||
|
||
fn from_request(request: &HttpRequest<S>, settings: &Self::Config) -> Self::Result { | ||
for header in request.headers().get_all("authorization") { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would be surprised and alarmed if we got a request with more than one authorization header, and we should error out rather than letting it through if one of them happens to be valid.
src/settings.rs
Outdated
#[derive(Debug, Deserialize)] | ||
pub struct Settings { | ||
pub debug: bool, | ||
pub port: u16, | ||
pub master_secret: Vec<u8>, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FWIW, it seems like this could have a clearer name like "master_token_secret" or something.
Thanks for the test data @rfk, I started to write a test case with it today but I'm still having a bit of trouble getting it to work. From reading the docs (both the protocol spec and the hawk-rs docs), I have the impression that I need some request data in order to validate the mac from the header: method, host, port and path. Obviously I can just read those from the actix request object during real execution, but what are they for your test data? I tried a few things using host and port from the |
Oh yeah, right, sorry. I didn't write these down at the time, so I generated some fresh ones:
|
Hey @rfk, sorry to keep bugging you about this, would you mind casting a fresh eye over my logic here to see if you can spot where I'm going wrong? My problem is that I'm still failing to validate the mac with that new test data you gave me. It feels like I might be deriving If you look in So that leaves me thinking that the key must be wrong. I did try messing about with it a bit, but not with any great confidence that it would produce a better result. Any ideas? |
(well either that, or I've got your test data wrong somehow...) |
src/auth/mod.rs
Outdated
hkdf.expand( | ||
format!("services.mozilla.com/tokenlib/v1/derive/{}", id).as_bytes(), | ||
&mut token_secret, | ||
)?; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I missed it in my initial description, but re-reading the python tokenlib
source code, I believe that token_secret
here needs to be b64urlsafe encoded before using it to verify the mac below. I'm having a bit of trouble building the project right now, but if I can get that working I'll put an encode step in here to check.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe it is supposed to be base64encoded.
https://github.com/mozilla-services/go-syncstorage/blob/master/token/token.go#L101
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎉 Woop woop! It's validating successfully now, thanks for the help with this!
Cargo.lock
Outdated
@@ -1301,6 +1403,18 @@ dependencies = [ | |||
"quick-error 1.2.2 (registry+https://github.com/rust-lang/crates.io-index)", | |||
] | |||
|
|||
[[package]] | |||
name = "ring" | |||
version = "0.8.1" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Both travis-ci and I are getting build failures like:
/home/travis/.cargo/registry/src/github.com-1ecc6299db9ec823/ring-0.12.1/crypto/curve25519/curve25519.c:4693: multiple definition of `GFp_x25519_public_from_private'
/home/travis/build/mozilla-services/syncstorage-rs/target/debug/deps/libring-ae014aa1941bdf5c.rlib(curve25519.o):/home/travis/.cargo/registry/src/github.com-1ecc6299db9ec823/ring-0.8.1/crypto/curve25519/curve25519.c:4712: first defined here
Could be because this is introducing a second version of ring
, and both versions can't be compiled together into a single binary?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could be because this is introducing a second version of
ring
...
Looks like it, yep. Thanks for the tip, I'll make sure I sort it before de-wipping this.
src/auth/mod.rs
Outdated
let payload: HawkPayload = serde_json::from_slice(payload)?; | ||
println!("{:?}", payload); | ||
|
||
// Check payload is not expired |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FYI in this commit I made go-syncstorage accept an environment configurable timestamp skew. In reality some client clocks are off by months and every sync request they sent was 403'd ... I wound up setting this to 1 year just so they could sync.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 Thanks for the tip, will do the same here!
Okay, I think this is close to being reviewable now, there's just one last thing that I'm unsure about. As @rfk pointed out in #20 (comment), the hawk crate depends on a different version of ring to actix. Although that doesn't seem to affect my build on MacOS, it causes linker errors about conflicting symbols in other environments. Initially I just forked An alternative, which I've fleshed out in a separate branch, would be to ditch the The exact same tests pass for both branches, so it seems like a low-risk proposal to me (but I'm a hawk/crypto novice so I realise that doesn't count for much). |
The hawk Rust crate is maintained by a Mozillian on the Taskcluster team, perhaps we can just ask @djmitche to upgrade the ring dependency? Or does actix use an older version? |
I don't do much rust these days, so I can try but I'd also be happy to
merge a PR (sadly). The code hasn't been touched for many Rust releases so
any updates would be fine too.
…On Fri, Sep 7, 2018, 6:55 PM Ben Bangert ***@***.***> wrote:
The hawk Rust crate is maintained by a Mozillian on the Taskcluster team,
perhaps we can just ask @djmitche <https://github.com/djmitche> to
upgrade the ring dependency? Or does actix use an older version?
—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
<#20 (comment)>,
or mute the thread
<https://github.com/notifications/unsubscribe-auth/AABwAcOhvjXuuIMDvoBsTBhSbpW-d6oEks5uYvlQgaJpZM4WR8Y_>
.
|
@djmitche Thanks! The application-services group is working on more Rust nowadays, so we'll likely need to use the hawk crate in multiple Rust projects going forward. Would it make sense to move the hawk crate to mozilla-services so we can iterate on it, or do you have the time to merge/release new iterations as we submit PR's? |
Well yep, of course. My question was more about whether there's a mechanism that works longer term than that. I don't know, some kind of name-mangling magic that lets different versions of the lib co-exist in the same binary? If not that's cool, it just seems annoying that we have to synchronise the sub-dependencies of two unrelated external dependencies every time we want to run
|
Let's keep it under the taskcluster org for now, since that's where it started. I'd like to stay involved -- I might just learn something! If it turns out I'm slowing down progress, we can either move it or I can add collaborators to the repo. |
It depends, sometimes the crate will work just fine depending on what constructs we need to pass it. If we need to pass it something impl a trait from one version of the dep, and the crate uses a different version we'll end up with a difficult to understand error. If we just call it with more basic types though, it should be fine that it uses one version of ring, while we use another elsewhere.... as long as they don't both try and bind to different versions of say, openssl. An alternative approach would be to see if it makes sense to change the API of the rust-hawk crate more substantially, such that the user must pass in an object impl a crypto trait for the operations hawk needs. This way the user can choose what ring/crypto libs are used, and the hawk crate can avoid being bound to any particular crypto dep version. We could add this as a lower-level API with feature flags, so that we could turn off the ring dep in rust-hawk and pass in our own object. |
Fwiw, just to be clear, that's not what we saw in CI. This repo and hawk were compiling against the exact same version of ring, and on the actix side no ring objects are passed between us. Indeed compilation itself was fine, there were no errors during that phase of the build. It was only when the linker kicked in that it complained about multiple definitions of symbols that come directly from C code in the ring lib (example). Looking at the GitHub stats, there's more C than Rust in the ring source code (and more assembler than C). I played around with the dependencies a bit and even for minor rev differences, the linker fails. But it never fails on my machine, which is what led to my question because if it's possible for the build to succeed on MacOS, it must be doing some kind of magic to address those symbol clashes.
I quite like the sound of this, although I haven't looked at the hawk crate to see how pervasive any coupling with ring is. Obviously we can kick this decision down the road for a bit now but if it comes up again soon I'll take a look into it. |
Possibly worth noting that this would be very useful for the rust-hawk client lib too. In different places in mozilla/application-services we're using https://github.com/eoger/rust-hawk/tree/use-openssl and https://github.com/eoger/rust-hawk/tree/use-ring-latest. We planned on doing something like that (exposing a trait) before upstreaming these, but never got around to it... |
I'd love to see those upstreamed. I didn't totally understand hawk at the
time I was writing rust-hawk (and anyway I think it's gotten several major
revisions since then).
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking good @philbooth! I'm r- here due to incorrect application of clock skew, but otherwise this looks good to me!
src/auth/mod.rs
Outdated
if request.validate_header( | ||
&header, | ||
&Key::new(token_secret.as_bytes(), &ring::digest::SHA256), | ||
Duration::weeks(52), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should add a comment to explain what this weeks(52)
is doing here, because is could easily raise eyebrows.
src/auth/mod.rs
Outdated
request.uri().port().ok_or(AuthError)?, | ||
settings, | ||
// Allow a generous amount of client skew for expiry | ||
Utc::now().timestamp() as u64 + YEAR, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This expiry
date applies to the expires
timestamp that is decoded from inside the Hawk id JSON payload, right? That timestamp is generated server-side by the tokenserver (and is covered by the HMAC signature check in extract_and_validate
) so we do not need to allow for client-side clock skew in that check. We should assume that the servers have well-synchronized clocks.
As written, this would allow tokenserver tokens to be valid for up to a year longer than intended, which we definitely don't want.
(The timestamp from the hawk header is client-generated and we can allow some generous clock skew on that one).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, my bad. I only added this after the suggestion in #20 (comment).
(just in case anyone looks at this PR overnight and wonders what's going on with it, I'm still trying to make the server tests pass with authentication turned on) |
The test failures appear to be related to the actix-web upgrade, so I'm waiting to see what comes out of actix/actix-web#508 before spending any more time on this. |
Problem will be fixed in actix-web 0.7.7, so I should have something to review here tomorrow. |
This is, at long last, ready for proper review (I hope). Sorry it took so long! 😕 I've wired the authentication into the handlers, but haven't gone any further than that in terms of checking I notice the |
key: Key::new(token_secret.as_bytes(), &ring::digest::SHA256), | ||
}; | ||
let header = request.make_header(&credentials).unwrap(); | ||
format!("Hawk {}", header) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I opted for a manual implementation in the tests here, rather than leaning on code in HawkPayload
, because I didn't want the possibility of a bug in production code leaking into the tests and preventing them from picking up on it.
pub master_token_secret: Vec<u8>, | ||
} | ||
|
||
impl Default for Settings { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Default
is required for the implementation of FromRequest
on HawkPayload
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The FromRequest Config's a little odd, it always gets the from_request() cfg via Config::default() (so HawkPayloads from_request will always get this bad master_token_secret returned here). I guess it's more of a hardcoded (at build time) configuration
What we should do instead is put Settings (or some subset of it) in the actix_web state and from_request can get it from req.state()
Also, side-note, the hawk crate includes some logging that shows up when authenticating requests (including in the tests). I've opened taskcluster/rust-hawk#12 to investigate the possibility of turning it off. |
src/auth/mod.rs
Outdated
expiry: u64, | ||
) -> AuthResult<HawkPayload> { | ||
let decoded_id = base64::decode_config(id, base64::URL_SAFE)?; | ||
let payload_length = decoded_id.len() - 32; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
could decoded_id be < 32 here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was about to say "no", but then I realised I really mean "not if it's a valid header". But of course we still need to fail gracefully for invalid input so I'll add a length check before this step.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good to me!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
^
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM (modulo @pjenvey's comments that is)
b"services.mozilla.com/tokenlib/v1/signing", | ||
None, | ||
master_secret, | ||
)?; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In a future refactor, you could consider pre-calculating this and caching it somewhere globally. The overhead will be pretty minimal in practice though so NBD.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We're going to need to implement a custom Deserialize
for this property on Settings
anyway, so could do it in there.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ref: #26
Yeah, so re: the Over in fxa-email-service we have a kind of cascade of configs that let us commit different defaults for local dev, tests, CI etc to the repo, without forcing the consuming code to complicate itself with the correct initialisation of config.merge(File::with_name("config/default"))?;
let current_env = match env::var("FXA_EMAIL_ENV") {
Ok(env) => env,
_ => String::from("dev"),
};
config.merge(File::with_name(&format!("config/{}", current_env)).required(false))?;
config.set_default("env", "dev")?;
config.merge(File::with_name("config/local").required(false))?;
let env = Environment::with_prefix("fxa_email");
config.merge(env.separator("_"))?; If we did this it would mean that we just need to update those default configs when new settings get added, rather than what I had to do in this PR which was go and make changes in the db tests as well as in the auth tests and in that implementation of the Instead of hard-coded default values, it could invoke something like the above code and let the I was going to propose all this in a separate PR, but I'm also happy to do it in this one if you think it's a pre-requisite for landing this stuff. Thoughts? |
But of course, the thing I didn't consider there is that the linked code returns a But fwiw, I'm still interested in whether the above proposal is of interest for separating the test code from it's dependency on |
I've made a change in d3995ee to put Fwiw I also tried giving (although if you guys can suggest a way to make it work, I'm keen to learn!) r? |
src/handlers.rs
Outdated
pub db: Box<Db>, | ||
} | ||
use db::{params, util::ms_since_epoch, DbError}; | ||
use server::ServerState; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Now that ServerState
is referenced in a few different places, it seemed to belong more in the server
module than in handlers
.
`FromRequest::Config` uses the `Default` trait to create dependencies, which is no good for us because we don't want to use a default value for the master token secret. Instead, this change pushes the master secret into the `ServerState` struct, where we can set it after reading from environment variables or a config file. One slight downside to this is that I had to wrap it in an `Arc`, to avoid cloning a fresh copy of the master secret in every instance of `ServerState`. I did try giving `ServerState` an explicit lifetime and borrowing the master secret as a slice, but I couldn't get that to play nicely with the requirement for a static lifetime on the `App` instance.
The Settings per environ setup sounds good Phil (you can nuke the Settings Default when adding it) Arc looks good here too. It's not worth setting up lifetimes for data like this setup once (or in the case of actix_web State, at least infrequently) -- the clones or the like just don't matter |
Fixes #19.
I didn't quite manage to get this finished today, mostly because I wasted a few hours writing parsing logic for the Hawk header, only to eventually RTFM and notice the hawk crate already implements that via the
FromStr
trait. Sigh.Anyway, I wanted to post this as a WIP so that @rfk can hopefully cast his eye over the crypto logic while I'm away. As you'll see Ryan, I've left your excellent (and long) comment in place for now, interjected with my attempts to implement each section of it. There are a couple of places where I'm not sure I understood you correctly, so I'll call those out in the inline comments.
If I have time after I've packed my bag tomorrow, I might see if I can wire it into the request mechanism and write some tests for it. If I do get to that point I'll comment here and remove the WIP. But, being realistic, I probably won't get to that point.
See you all next week! 😄