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

Add WebAuthn support to GUI (fixes #8409) #8417

Closed
wants to merge 34 commits into from
Closed

Conversation

emlun
Copy link
Member

@emlun emlun commented Jul 5, 2022

Fixes #8409.

Remaining things to do

  • Rewrite data type changes in protobuf
  • Store and validate signature counter
  • Store transports and return in allowCredentials Not currently supported by duo-labs/webauthn
  • Remove backwards-incompatible template string syntax in JS
  • Remove basic auth prompt
  • Proper password auth API endpoint
  • WebAuthn failure messages on login page
  • Warning on login page when on raw IP address
  • Migrate from deprecated duo-labs/webauthn to successor fork
  • Add tests
  • Open documentation PR

Purpose

This adds support for WebAuthn authentication in the GUI. WebAuthn is a W3C standard that provides an API for strong, privacy-conscious public-key authentication, which can enable a both more pleasant and more secure user experience than traditional password authentication. WebAuthn both works with external security keys (typically via USB or NFC) and is built into many modern browsers and operating systems.

For users who already use WebAuthn, this both cuts out a detour through a password manager and improves security by making it possible to not need a password at all.

Testing

Manual tests performed:

  • With no WebAuthn credentials enrolled:
    • If no password set, no authentication is required
    • Basic authorization prompt appears like before Removed!
  • With WebAuthn credentials enrolled:
    • With password set, Basic authorization headers still work as before
    • An HTML login page with both WebAuthn and password options is presented
    • Password authentication in HTML form works (no WebAuthn required)
    • WebAuthn authentication in HTML form works (no password required)
    • WebAuthn authentication is available immediately without server restart
  • WebAuthn settings/credential management:
    • Warning appears when HTTPS is disabled
    • Warning appears when RP ID does not match page domain
    • Warning appears when WebAuthn origin does not match RP ID
    • Warning appears when changing RP ID
    • New credentials appear immediately in the table in GUI settings
    • Renaming credentials takes effect on saving the GUI settings
    • Credential names fall back to credential ID when not set
    • Deleting credentials takes effect on saving the GUI settings
    • PUT /rest/config ignores WebAuthn credentials not already registered (identified by ID)
    • excludeCredentials is used to prevent registering the same authenticator twice

I intend to add automated tests as well, but would first like a review of the overall design and how it fits into the existing architecture.

Screenshots

HTML login form
HTML login form

New GUI settings
New GUI settings

Registering a new credential
Registering a new credential

Renaming a credential
Renaming a credential

Interactive warnings
Interactive warnings

Documentation

Probably a new section should be added alongside "LDAP Authentication". WebAuthn normally doesn't require much technical knowledge from the user since you only interact with the end-user side, but in the Syncthing case you are both end-user and server administrator. WebAuthn requires some domain settings to line up, so the "RP ID" and "WebAuthn Origin" settings and their impact need to be explained here. I volunteer to write this documentation once I have the green light to finish the feature.

@emlun emlun marked this pull request as draft July 5, 2022 13:45
@tomasz1986
Copy link
Contributor

Translations are handled by Transifex, so you shouldn't add anything manually, except for the English strings, which are optional also (as the script will add them later anyway).

Another question is whether this should be added to the "main" GUI (as the commit currently does) or rather stay in the Advanced Configuration only (as it is the case with LDAP).

go.mod Outdated Show resolved Hide resolved
lib/api/api.go Outdated Show resolved Hide resolved
createSession(s.cookieName, guiCfg.User, guiCfg, s.evLogger, w, r)

return true
}
Copy link
Member Author

Choose a reason for hiding this comment

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

It feels a bit weird that these {start|finish}WebauthnAuthentication are in a different file than the {start|finish}WebauthnRegistration methods in confighandler.go, but they do belong to different sections of the API. But perhaps they should both be moved out into one shared separate file and imported from there?

Nickname string `protobuf:"bytes,2,opt,name=nickname,proto3" json:"nickname" xml:"nickname,attr"`
PublicKeyCose string `protobuf:"bytes,3,opt,name=publicKeyCose,proto3" json:"publicKeyCose" xml:"publicKeyCose,attr" restart:"false"`
}

Copy link
Member Author

Choose a reason for hiding this comment

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

Perhaps this should be in a separate file? Or further down in this file? I'm not sure.

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh, and I'm not sure whether the protobuf tags are needed here, or on the new GUIConfiguration fields. At least, it seems I didn't have to make any changes to the XXX_* methods in order for WebAuthn to work. Do we need those for this?

Copy link
Member

Choose a reason for hiding this comment

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

These .pb.go-files are auto generated. The proper way to add fields is to add them to the .proto files under proto/ at the repo top level, then run go run build.go proto (which may need some dependencies, I don't fully remember...).

Copy link
Member Author

Choose a reason for hiding this comment

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

Alright, thanks! Should be fixed now.

@calmh
Copy link
Member

calmh commented Jul 5, 2022

That's awesome! I'll look into this and answer questions. In the meantime, after just glancing at your description, I think it would be nice to axe the http basic auth prompt completely. That is, if we have a real login page, we should present that always. (Possibly excepting the case where basic auth is present without us having prompted for it, like in a wrapper that adds it automatically.)

@tomasz1986
Copy link
Contributor

tomasz1986 commented Jul 5, 2022

This seems to have poor browser support though. I've tested the code locally, and the GUI doesn't load in IE11 at all. Also, according to https://webauthn.me/browser-support and https://caniuse.com/webauthn, the minimum Android requirement is version 7+, while Syncthing itself currently supports Android 4.1.

Would it be possible to load the new authentication only when the browser/wrapper supports it?

@emlun
Copy link
Member Author

emlun commented Jul 5, 2022

I think it would be nice to axe the http basic auth prompt completely

Great, I agree! And yes, we can easily keep checking for the presence of the Authorization header and accept that if present, but just not return the WWW-Authenticate header if not.

And right, I meant to point something out in the diff but forgot to: currently the login form works by sending an XHR with the Authorization header, just to get the cookie, and then reloading the page with JavaScript - just because that was easy to get working and didn't need any more changes to the backend. But especially if we're dropping the basic auth prompt, I think it's probably cleaner to add a proper auth endpoint to the backend and have the <form> actually POST to that instead.

the GUI doesn't load in IE11 at all. [...]
Would it be possible to load the new authentication only when the browser/wrapper supports it?

Hm, I thought this check would fix that, but honestly I forgot to test it because I don't have a non-compatible browser on hand. Do you get any error in the JavaScript console?

@tomasz1986
Copy link
Contributor

tomasz1986 commented Jul 5, 2022

Hm, I thought this check would fix that, but honestly I forgot to test it because I don't have a non-compatible browser on hand. Do you get any error in the JavaScript console?

Sure, this is the output.

SCRIPT1002: Syntax error
webauthn-json.browser-global.js (1,5)
SCRIPT1014: Invalid character
syncthingController.js (1546,28)
Error: [ng:areq] Argument 'SyncthingController' is not a function, got undefined
http://errors.angularjs.org/1.3.20/ng/areq?p0=SyncthingController&p1=not%20a%20function%2C%20got%20undefined
   at assertArg (https://127.0.0.1:8384/vendor/angular/angular.js:1609:5)
   at assertArgFn (https://127.0.0.1:8384/vendor/angular/angular.js:1619:3)
   at Anonymous function (https://127.0.0.1:8384/vendor/angular/angular.js:8512:9)
   at Anonymous function (https://127.0.0.1:8384/vendor/angular/angular.js:7680:13)
   at forEach (https://127.0.0.1:8384/vendor/angular/angular.js:353:11)
   at nodeLinkFn (https://127.0.0.1:8384/vendor/angular/angular.js:7667:11)
   at compositeLinkFn (https://127.0.0.1:8384/vendor/angular/angular.js:7159:13)
   at publicLinkFn (https://127.0.0.1:8384/vendor/angular/angular.js:7038:30)
   at Anonymous function (https://127.0.0.1:8384/vendor/angular/angular.js:1479:11)
   at Scope.prototype.$eval (https://127.0.0.1:8384/vendor/angular/angular.js:14589:9)

The problem is about using backticks in https://github.com/syncthing/syncthing/pull/8417/files#diff-77bf455925e2b856359d0a3f63bd07b75fe2990adf70d4996de0df0a0cfe8b29R1546. The fix is at https://stackoverflow.com/a/43048518.

The other error still persists though, although the GUI does load now.

SCRIPT1002: Syntax error
webauthn-json.browser-global.js (1,5)

Edit:

This is unrelated, but I'm seeing the following in all browsers

image

with the cryptic %currentDomain%.

@bt90
Copy link
Contributor

bt90 commented Jul 5, 2022

As we're adding a proper loginpage (yay!) could we also fix the favicon for password managers while we're at it?

#7676

tl;dr provide a static /favicon.ico without requiring authentication

@emlun
Copy link
Member Author

emlun commented Jul 6, 2022

The problem is about using backticks

Ah, of course. Will fix, thanks!

This is unrelated, but I'm seeing the following in all browsers [...] with the cryptic %currentDomain%.

I believe this is because the translation string isn't in place, at least it seemed to fix itself when I added them. You can try running on commit 9dea90a to confirm.

As we're adding a proper loginpage (yay!) could we also fix the favicon for password managers while we're at it?

Hmaybe? The favicon assets /assets/img/favicon-*.png are now also in the unauthenticated section, but it looks like we're not actually serving a /favicon.ico, instead it's a <link rel="shortcut icon"> with a dynamic path. But if we just add a static /favicon.ico, that will also be available without authentication.

@bt90
Copy link
Contributor

bt90 commented Jul 6, 2022

At least for Bitwarden it should be enough to have the initial favicon in the HTML without requiring JS and the image to be reachable without authentication.

… with UI state

Before, it was easy for backend state and UI state to end up with
conflicts. For example, if you delete a credential in the frontend and
attempt to register the same authenticator again before saving, the
backend would still include the deleted credential in
`excludeCredentials` and the authenticator wouldn't be able to be
registered again.

By instead setting `excludeCredentials` in the frontend, the above
scenario is no longer a problem because the `excludeCredentials`
setting is consistent with what's shown in the UI.

There is still a remaining state conflict, though: if you cancel the
settings dialog without saving, any credentials registered will remain
registered even though they "should" be removed because the UI state
wasn't explicitly saved.
@emlun
Copy link
Member Author

emlun commented Jul 14, 2022

Alright, I've fixed some of the issues encountered so far, and added a todo checklist to the top post. @tomasz1986 please take another look at the support for older browsers! I'll be back with updates for the remaining todo items.

@bt90 I'm pretty sure the favicon can be done quite easily once the auth-split router is in place, but it seems out of scope for this PR.

@bt90
Copy link
Contributor

bt90 commented Jul 14, 2022 via email

@emlun emlun changed the title Add Webauthn support to GUI (fixes #8409) Add WebAuthn support to GUI (fixes #8409) Jan 7, 2023
@emlun emlun requested review from acolomb and tomasz1986 and removed request for acolomb and tomasz1986 January 7, 2023 21:27
@tomasz1986
Copy link
Contributor

tomasz1986 commented Jan 7, 2023

I was going to do a quick test right now, however I'm getting stuck right at the very beginning.

On the first try, I input the username and password, then clicked save, then immediately refreshed the page. The GUI got stuck at the standard login prompt. However, the username and password weren't working at all.

I decided to try again. I wiped out the config and started from scratch. I followed the same steps, however after saving the changes, I waited a few seconds and only then refreshed the GUI. This time the new login screen loaded. I logged in, then opened the settings and removed both username and password, then saved the changes, then refreshed the GUI, and this time again the GUI got stuck at the standard login prompt 😵.

Edit: The same happens when pressing the "Save changes and reload page…" button as well. Also, on the login screen, most of the buttons/modals inside the Actions menu are either broken or don't load anything useful. It will probably be good to simply disable most of them. For the record, I'm testing everything in Chromium.

Just for information, this is all with a completely clean installation and a freshly generated config.xml file.

image

image

@emlun
Copy link
Member Author

emlun commented Jan 7, 2023

Thanks @tomasz1986! I'll look into these issues tomorrow.

@tomasz1986
Copy link
Contributor

tomasz1986 commented Jan 8, 2023

Please ignore the above (#8417 (comment)). The problem was that in the past, when launching a new instance of Syncthing, it used to select a random port for the GUI if the default 8384 was in use. However, for some time now, the new instance has launched with the same 8384 port which works for its first load in the browser, however if you refresh the site, it tries to log in to the original instance of Syncthing, and thus it fails. Not sure why a random port isn't used anymore, but I don't think the problem is related to this PR at all 🙂.

When it comes to the actual testing, here are some of my comments and questions. I've also added a few others directly in the code.

Firstly, when it comes to the HTML.

  1. "Actions -> Settings" doesn't open at all.
  2. "Actions -> Show ID" opens but shows nothing.
  3. "Actions -> Shutdown" doesn't work at all.
  4. "Actions -> Restart" appears to be working but in reality nothing happens.
  5. "Actions -> About" opens but the version string and paths are missing.
  6. "Actions -> Advanced" doesn't open at all.
  7. "Actions -> Logs" opens but shows nothing.
  8. "Documentation" link has no version number in it (i.e. https://docs.syncthing.net/?version=).
  9. Is it unavoidable to use embedded styles with style attribute instead of classes with CSS?
  10. Is the hard-coded tabindex necessary? Which elements get focused first if you remove it?
  11. The translate attribute is usually the last in the element. Very minor, but it makes it easier to spot which elements are set to translate and which are not.

Now, the above were just a few comments, however, this is the big one. How does the new login cooperate with the Android app? Not sure if you're aware, but the app currently uses a hard-coded username of "syncthing" and the password set to the API key. The user isn't allowed to change them (or they can try, but the two get reversed upon restart).

  1. Does the app still work as before with this PR implemented?
  2. If the app itself works, what about the Web GUI?
  3. If the Web GUI works, what happens if the user attempts to add new credentials? Is the app still able to revert them, or does doing so break the app (the worst case scenario)?

@bt90
Copy link
Contributor

bt90 commented Jan 8, 2023

At least from my understanding, only the Basic Auth prompt is removed. So the web UI integration should still behave seamlessly:

Basic authorization headers still work as before

Keeping the credentials locked is a valid concern though.

@@ -92,6 +93,7 @@ type Wrapper interface {
RawCopy() Configuration
RequiresRestart() bool
Save() error
Finish(w http.ResponseWriter, waiter Waiter)
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 an api specific utility, i.e. shouldn't live in the config package and remain in the api package.
Incidentlally it's also what makes CI break (and tests not run), as you'd need to run go run build.go testmocks to regenerate the mock struct for this interface. However not needed when just not adding it here.

Copy link
Member Author

Choose a reason for hiding this comment

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

Ok, thanks, I'll see if I can refactor this.

Comment on lines +399 to +401
if updatedCred.Authenticator.CloneWarning && signCountBefore != 0 {
l.Warnln(fmt.Sprintf("Invalid WebAuthn signature count for credential \"%s\": expected > %d, was: %d. The credential may have been cloned.", authenticatedCredName, signCountBefore, parsedResponse.Response.AuthenticatorData.Counter))
}
Copy link
Member

Choose a reason for hiding this comment

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

Can you explain what this counter mechanism does?

Also the messages mentions a comparison to the auth response thing, but the actual condition is just > 0.

And the counter isn't config, right? that should go into the db not the config.

Copy link
Member Author

Choose a reason for hiding this comment

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

It's an optional feature that WebAuthn authenticators can implement, in which case every authentication signature by the credential will include a monotonically increasing counter value. So if a new authentication signature has a counter value less than or equal to the last seen counter value, then that means there likely exists two copies of the credential private key. But the feature is optional, and authenticators that don't implement it instead leave the counter constant at zero, so the check is only made if either the new or the last seen counter value is nonzero. You can read more about it here. But the library encapsulates all that in the CloneWarning output, so the zero check here is unrelated.

I think the != 0 check here is just because as far as I understand, the Go convention for searching in a list is to initialize to a sentinel value and use a for loop (unlike, say, Rust where you'd .find(...).map(...) -> Option<u32>). I'd love to be wrong about that and learn there's a better way, though. 🙂

And the counter isn't config, right? that should go into the db not the config.

Hmyeah, I guess so? In that case the whole credential entries should perhaps go in the database instead, as it's not really feasible to type them in manually either. How do you reckon that would work with configuring them in the settings view? Where should I start looking at how to work with the database?

@@ -59,25 +59,88 @@ require (
)

require (
github.com/calmh/incontainer v0.0.0-20221224152218-b3e71b103d7a
github.com/duo-labs/webauthn v0.0.0-20221205164246-ebaf9b74c6ec
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 an archived repo, with it's readme point out this as it's successor: https://github.com/go-webauthn/webauthn

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, I noticed but haven't gotten around to migrating it yet. I'll put it on the todo list.

@imsodin
Copy link
Member

imsodin commented Jan 8, 2023

I just had a quick look over this PR to get a high level understanding of what's going on, and trying to get into the weeds of the api backend implementation I can only echo this:

I'm not sure if it makes sense, but you could split the PR and create one for the login page and another for the webauthn logic.

It would help me a lot with review if this were broken up into smaller bits - single PR and multiple clean commits or multiple PRs. From a quick glance splitting like proposed in login page (possible pure refactor first?) and webauthn should work and makes sense semantically - anything else is fine with me too. I know what you really want is webauthn - I promise I'll review both (when I review either - can't promise anything there, wouldn't be honest with my current track record. And you can easily enforce it by not addressing my review if I were to ignore the webauthn bit :) ).

@emlun
Copy link
Member Author

emlun commented Jan 8, 2023

@tomasz1986

Please ignore the above [...] I don't think the problem is related to this PR at all slightly_smiling_face.

Ok, thanks, glad to hear it. 🙂

When it comes to the actual testing, here are some of my comments and questions. [...]

Thanks, I'll look into them!

Now, the above were just a few comments, however, this is the big one. How does the new login cooperate with the Android app? [...]

Hm, I haven't tested, but I assume the app uses HTTP Basic Authorization then? In that case I think the app should be unaffected, but for it to continue working in a standard browser it probably needs the advanced setting to be enabled by default in the server. The new login form should work, of course, but you'd need to know that API key and hard-coded username and without the setting it probably won't work to just open a URL with embedded basic auth from within the app.

@emlun
Copy link
Member Author

emlun commented Jan 8, 2023

@bt90 @imsodin Ok, I'll reduce this PR to just adding the new login form and split off the WebAuthn additions to a separate PR. Or would you prefer closing this one and opening two completely new ones?

@imsodin
Copy link
Member

imsodin commented Jan 8, 2023

Whatever works best for you - reducing this one is perfectly fine for me

@tomasz1986
Copy link
Contributor

tomasz1986 commented Jan 8, 2023

Hm, I haven't tested, but I assume the app uses HTTP Basic Authorization then? In that case I think the app should be unaffected, but for it to continue working in a standard browser it probably needs the advanced setting to be enabled by default in the server. The new login form should work, of course, but you'd need to know that API key and hard-coded username and without the setting it probably won't work to just open a URL with embedded basic auth from within the app.

Yeah, I don't know the details when it comes to the app code, but if the advanced setting is indeed required, then a) it would need to be enabled automatically upon upgrade or otherwise the user may be stuck with inaccessible Syncthing, and b) the setting would have to be enforced, so that the user basically can't change it (similarly to the currently hard-coded username and password). Basically, the app mustn't allow the user to break it 😛. This is especially true as once locked out, the user probably won't know the API key, and in contrary to the desktop, there is no way to even access config.xml on Android to restore it (without root).

I've got the build environment for the app already set up, so I should be able to test what actually happens when trying to use the GUI code from this PR.

@acolomb
Copy link
Member

acolomb commented Jan 8, 2023

This is especially true as once locked out, the user probably won't know the API key, and in contrary to the desktop, there is no way to even access config.xml on Android to restore it (without root).

Well I suppose the app itself does have access to the file, so could of course restore access when the service is not running.

@emlun
Copy link
Member Author

emlun commented Jan 23, 2023

I realized I can't change the branch name of this PR, and it doesn't make much sense to have branch webauthn contain nothing related to WebAuthn :) so I extracted a new branch and PR with only the new login page: #8757. Should I also make a concurrent PR (either this or a new one) that isolates the changes for adding WebAuthn?

@imsodin
Copy link
Member

imsodin commented Jan 23, 2023

I realized I can't change the branch name of this PR, and it doesn't make much sense to have branch webauthn contain nothing related to WebAuthn :) so I extracted a new branch and PR with only the new login page: #8757. Should I also make a concurrent PR (either this or a new one) that isolates the changes for adding WebAuthn?

Whatever works best for you. If the webauthn PR builds on top of the login page one, you might want to PR against the login page branch. Then only the relevant changes are shown in the PR, and once the login page one is merged it will automatically target main.

@st-review
Copy link

🤖 beep boop

I'm going to close this pull request as it has been idle for more than 90 days.

This is not a rejection, merely a removal from the list of active pull requests that is periodically reviewed by humans. The pull request can be reopened when there is new activity, and merged once any remaining issues are resolved.

To permanently exempt this PR from bot nagging, add the slow-pr label.

@st-review st-review closed this Apr 23, 2023
@emlun
Copy link
Member Author

emlun commented Oct 6, 2023

#8757 is now merged! With that I'll (soon 😅) proceed to revive this with the remaining additions needed to also support WebAuthn. I'll probably start fresh with a new pull request, but I'll post a link here for the record.

@emlun
Copy link
Member Author

emlun commented Oct 15, 2023

This PR is now replaced by PR #9175.

@st-review st-review added the frozen-due-to-age Issues closed and untouched for a long time, together with being locked for discussion label Apr 23, 2024
@syncthing syncthing locked and limited conversation to collaborators Apr 23, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
frozen-due-to-age Issues closed and untouched for a long time, together with being locked for discussion
Projects
None yet
Development

Successfully merging this pull request may close these issues.

WebAuthn support in GUI
7 participants