├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── LICENSE-examples ├── PATENTS ├── README.md ├── circle.yml ├── examples ├── java │ ├── README.md │ ├── pom.xml │ └── src │ │ └── main │ │ ├── java │ │ └── com │ │ │ └── fbsamples │ │ │ └── delegatedrecovery │ │ │ └── sparkapp │ │ │ ├── Main.java │ │ │ ├── Path.java │ │ │ ├── RecoverAccountController.java │ │ │ ├── RecoveryTokenRecord.java │ │ │ ├── RecoveryTokenRecordDao.java │ │ │ └── SaveTokenController.java │ │ └── resources │ │ ├── static │ │ ├── icon.png │ │ ├── privacy.html │ │ └── style.css │ │ └── templates │ │ ├── identify_account.mustache │ │ ├── index.mustache │ │ ├── invalidate.mustache │ │ ├── no_token.mustache │ │ ├── recover_account.mustache │ │ ├── recover_account_failure.mustache │ │ ├── recover_account_success.mustache │ │ ├── renew.mustache │ │ ├── save.mustache │ │ ├── save_token_failure.mustache │ │ ├── save_token_success.mustache │ │ └── save_token_unknown.mustache └── nodejs │ ├── .eslintrc.json │ ├── Procfile │ ├── README.md │ ├── app.json │ ├── index.js │ ├── package.json │ ├── path.js │ └── static │ ├── icon.png │ ├── privacy.html │ ├── style.css │ └── templates │ ├── identify_account.mustache │ ├── index.mustache │ ├── invalidate.mustache │ ├── no_token.mustache │ ├── recover_account.mustache │ ├── recover_account_failure.mustache │ ├── recover_account_success.mustache │ ├── renew.mustache │ ├── save.mustache │ ├── save_token_failure.mustache │ ├── save_token_success.mustache │ └── save_token_unknown.mustache ├── pom.xml └── sdk ├── java-src ├── pom.xml └── src │ ├── license │ └── LICENSE-HEADER.txt │ └── main │ └── java │ └── com │ └── facebook │ └── delegatedrecovery │ ├── AccountProviderConfiguration.java │ ├── CountersignedRecoveryToken.java │ ├── DelegatedRecoveryConfiguration.java │ ├── DelegatedRecoveryUtils.java │ ├── InvalidOriginException.java │ ├── InvalidTokenException.java │ ├── RecoveryProviderConfiguration.java │ └── RecoveryToken.java └── js-src ├── .eslintrc.json └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | *.iml 4 | *.pem 5 | sdk/java-src/target 6 | examples/java/target 7 | heroku.properties 8 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | Facebook has adopted a Code of Conduct that we expect project participants to adhere to. Please [read the full text](https://code.facebook.com/codeofconduct) so that you can understand what actions will and will not be tolerated. 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to DelegatedRecoveryReferenceImplementation 2 | 3 | This project contains early access sample application and alpha SDK. 4 | 5 | Please [open an issue](https://github.com/facebook/DelegatedRecoveryReferenceImplementation/issues) 6 | for any bugs or feature requests. 7 | 8 | We are not accepting pull requests at this time. 9 | 10 | ## Issues 11 | We use GitHub issues to track public bugs. Please ensure your description is 12 | clear and has sufficient instructions to be able to reproduce the issue. 13 | 14 | Facebook has a [bounty program](https://www.facebook.com/whitehat/) for the safe 15 | disclosure of security bugs. In those cases, please go through the process 16 | outlined on that page and do not file a public issue. 17 | 18 | ## License 19 | By contributing to DelegatedRecoveryReferenceImplementation, you agree that your contributions 20 | will be licensed under the LICENSE file in the root directory of this source tree. 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD License 2 | 3 | For DelegatedRecoverReferenceImplementation software 4 | 5 | Copyright (c) 2016-present, Facebook, Inc. All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without modification, 8 | are permitted provided that the following conditions are met: 9 | 10 | * Redistributions of source code must retain the above copyright notice, this 11 | list of conditions and the following disclaimer. 12 | 13 | * Redistributions in binary form must reproduce the above copyright notice, 14 | this list of conditions and the following disclaimer in the documentation 15 | and/or other materials provided with the distribution. 16 | 17 | * Neither the name Facebook nor the names of its contributors may be used to 18 | endorse or promote products derived from this software without specific 19 | prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 22 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 23 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 25 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 26 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 27 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 28 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /LICENSE-examples: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-present, Facebook, Inc. All rights reserved. 2 | 3 | The examples provided by Facebook are for non-commercial testing and evaluation 4 | purposes only. Facebook reserves all rights not expressly granted. 5 | 6 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 7 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 8 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 9 | FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 10 | ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 11 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 12 | -------------------------------------------------------------------------------- /PATENTS: -------------------------------------------------------------------------------- 1 | Additional Grant of Patent Rights Version 2 2 | 3 | "Software" means the DelegatedRecoveryReferenceImplementation software contributed by Facebook, Inc. 4 | 5 | Facebook, Inc. ("Facebook") hereby grants to each recipient of the Software 6 | ("you") a perpetual, worldwide, royalty-free, non-exclusive, irrevocable 7 | (subject to the termination provision below) license under any Necessary 8 | Claims, to make, have made, use, sell, offer to sell, import, and otherwise 9 | transfer the Software. For avoidance of doubt, no license is granted under 10 | Facebook’s rights in any patent claims that are infringed by (i) modifications 11 | to the Software made by you or any third party or (ii) the Software in 12 | combination with any software or other technology. 13 | 14 | The license granted hereunder will terminate, automatically and without notice, 15 | if you (or any of your subsidiaries, corporate affiliates or agents) initiate 16 | directly or indirectly, or take a direct financial interest in, any Patent 17 | Assertion: (i) against Facebook or any of its subsidiaries or corporate 18 | affiliates, (ii) against any party if such Patent Assertion arises in whole or 19 | in part from any software, technology, product or service of Facebook or any of 20 | its subsidiaries or corporate affiliates, or (iii) against any party relating 21 | to the Software. Notwithstanding the foregoing, if Facebook or any of its 22 | subsidiaries or corporate affiliates files a lawsuit alleging patent 23 | infringement against you in the first instance, and you respond by filing a 24 | patent infringement counterclaim in that lawsuit against that party that is 25 | unrelated to the Software, the license granted hereunder will not terminate 26 | under section (i) of this paragraph due to such counterclaim. 27 | 28 | A "Necessary Claim" is a claim of a patent owned by Facebook that is 29 | necessarily infringed by the Software standing alone. 30 | 31 | A "Patent Assertion" is any lawsuit or other action alleging direct, indirect, 32 | or contributory infringement or inducement to infringe any patent, including a 33 | cross-claim or counterclaim. 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DelegatedRecoveryReferenceImplementation 2 | 3 | This repository contains alpha SDKs and sample applications for using 4 | delegated account recovery with Facebook. 5 | 6 | Code under the /sdk directory is licensed under the BSD-style license found in the 7 | LICENSE file in the root directory of this source tree. An additional grant 8 | of patent rights can be found in the PATENTS file in the same directory. 9 | 10 | **These SDKs are alpha and subject to change.** 11 | 12 | Code under the /examples directory is licensed under the license found in the 13 | LICENSE-examples file in the root directory of this source tree. 14 | 15 | **The example applications are for non-commercial testing and evaluation purposes only.** 16 | 17 | ## Specification 18 | See the protocol specification for additional information on the delegated recovery protocol: 19 | [https://github.com/facebook/DelegatedRecoverySpecification](https://github.com/facebook/DelegatedRecoverySpecification) 20 | 21 | ## Example Applications 22 | Example application for NodeJS: [README.md](https://github.com/facebook/DelegatedRecoveryReferenceImplementation/blob/master/examples/nodejs/README.md) 23 | 24 | Example application for Java: [README.md](https://github.com/facebook/DelegatedRecoveryReferenceImplementation/blob/master/examples/java/README.md) 25 | 26 | ## Developer documentation 27 | To use the SDKs, please see documentation available at [https://developers.facebook.com/docs/delegated-recovery/](https://developers.facebook.com/docs/delegated-recovery/) 28 | 29 | ## Contributing 30 | See [CONTRIBUTING.md](https://github.com/facebook/DelegatedRecoveryReferenceImplementation/blob/master/CONTRIBUTING.md) 31 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | dependencies: 2 | override: 3 | - mvn package 4 | post: 5 | - npm i -g eslint 6 | - npm i -g eslint-plugin-json 7 | 8 | test: 9 | override: 10 | - eslint ./sdk/js-src/index.js -------------------------------------------------------------------------------- /examples/java/README.md: -------------------------------------------------------------------------------- 1 | # Delegated Account Recovery Example Application for Java 2 | The "examples/java" directory of 3 | [https://github.com/facebook/DelegatedRecoveryReferenceImplementation](https://github.com/facebook/DelegatedRecoveryReferenceImplementation) provides an example web application and library for using the Delegated Account 4 | Recovery protocol documented at [https://github.com/facebook/DelegatedRecoveryReferenceImplementation](https://github.com/facebook/DelegatedRecoveryReferenceImplementation) 5 | 6 | **This is an alpha implementation and subject to change.** 7 | 8 | ## Sample app 9 | The `com.fbsamples.delegatedrecovery.sparkapp` package contains a sample 10 | app that demonstrates the basic features of using delegated account recovery 11 | with Facebook. It is intended to demonstrate concepts and is for evaluation purposes only. 12 | 13 | ## Dependencies 14 | Java version 1.8 is required. 15 | 16 | The sample app is built using the [Spark Framework](https://sparkjava.com/). 17 | 18 | The application is built to deploy on [Heroku](https://heroku.com/). 19 | 20 | The overall project is build using [Maven](https://maven.apache.org/) and its 21 | dependencies are listed in the `pom.xml` file. 22 | 23 | ## Installation 24 | Begin by forking the repository. In the top right corner of [the repository home page on GitHub](https://github.com/facebook/DelegatedRecoveryReferenceImplementation), click **Fork** ![Fork](https://help.github.com/assets/images/help/repository/fork_button.jpg) 25 | 26 | Now, in your bash command line, get a copy of the forked repository. 27 | ```bash 28 | $ git clone https://github.com/{your-github-username}/DelegatedRecoveryReferenceImplementation 29 | ``` 30 | 31 | Change to the sample application directory of your cloned repository 32 | ```bash 33 | $ cd DelegatedRecoveryReferenceImplementation/examples/java 34 | ``` 35 | 36 | To deploy, pick a name for your app on Heroku. Using the command line Heroku toolbelt, 37 | create the app. 38 | ```bash 39 | $ heroku create my-app-name 40 | ``` 41 | 42 | Then create a file called 'heroku.properties' that defines your app name 43 | ```bash 44 | $ echo "heroku.appName=my-app-name" >> heroku.properties 45 | $ echo "heroku.properties" >> .gitignore 46 | ``` 47 | 48 | Next, you need to set some config variables for the application. 49 | You must have a recent build of openssl to complete this step. 50 | 51 | First set the issuer origin: 52 | 53 | ```bash 54 | $ heroku config:set ISSUER_ORIGIN=https://{my-app-name}.herokuapp.com --app my-app-name 55 | ``` 56 | 57 | Create the assymetric key pair for signing recovery tokens. 58 | ```bash 59 | $ openssl ecparam -name prime256v1 -genkey -noout -out prime256v1-key.pem 60 | $ openssl ec -in prime256v1-key.pem -pubout -out prime256v1-pub.pem 61 | ``` 62 | 63 | Make sure you don't check the secret keys into your source control. It is 64 | important to keep a backup of every private key and symmetric key ever 65 | used in order to verify and ecrypt tokens being returned to your app as part 66 | a recovery, but it's always a bad idea to keep secrets in source control. 67 | (it's fine to check in the public key if you want) 68 | ```bash 69 | $ echo "*.pem" >> .gitignore 70 | ``` 71 | 72 | And now we'll strip the PEM files down to unadorned, single-line base64 73 | for use as config variables. 74 | ```bash 75 | $ heroku config:set RECOVERY_PRIVATE_KEY=`perl -p -e 's/\R//g; s/-----[\w\s]+-----//' prime256v1-key.pem` --app my-app-name 76 | $ heroku config:set RECOVERY_PUBLIC_KEY=`perl -p -e 's/\R//g; s/-----[\w\s]+-----//' prime256v1-pub.pem` --app my-app-name 77 | ``` 78 | 79 | You can see your current configuration using: 80 | ```bash 81 | $ heroku config --app my-app-name 82 | ``` 83 | 84 | And deploy with Maven 85 | ```bash 86 | $ mvn heroku:deploy 87 | ``` 88 | 89 | Check that your application deployed successfully with these configuration variables from the command line: 90 | ```bash 91 | $ curl https://{your-app-name}.herokuapp.com/.well-known/delegated-account-recovery/configuration 92 | ``` 93 | 94 | You should get a JSON file that lists your public key as the first entry in the 95 | array that is the value of the key `tokensign-pubkeys-secp256r1` 96 | 97 | You can try the application itself by running: 98 | 99 | ```bash 100 | $ heroku open --app my-app-name 101 | ``` 102 | 103 | During the closed beta, you will only be able to use the sample applications when logging in to Facebook with a whitehat test account. [Create and manage test accounts here](https://www.facebook.com/whitehat/accounts). 104 | -------------------------------------------------------------------------------- /examples/java/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | com.fbsamples.recoveryApp 7 | recovery-app 8 | java-recovery-app 9 | 0.1 10 | 11 | 12 | 13 | com.facebook.delegatedrecovery 14 | delegatedrecovery-sdk 15 | 1.0.1 16 | 17 | 18 | com.sparkjava 19 | spark-core 20 | 2.7.2 21 | 22 | 23 | com.sparkjava 24 | spark-debug-tools 25 | 0.5 26 | 27 | 28 | org.glassfish 29 | javax.json 30 | 1.0.4 31 | 32 | 33 | com.sparkjava 34 | spark-template-mustache 35 | 2.5.5 36 | 37 | 38 | org.slf4j 39 | slf4j-simple 40 | 1.7.9 41 | 42 | 43 | 44 | 45 | 46 | org.apache.maven.plugins 47 | maven-compiler-plugin 48 | 2.3.2 49 | 50 | 1.8 51 | 1.8 52 | 53 | 54 | 55 | maven-assembly-plugin 56 | 57 | 58 | package 59 | 60 | single 61 | 62 | 63 | 64 | 65 | 66 | 67 | jar-with-dependencies 68 | 69 | 70 | 71 | com.fbsamples.delegatedrecovery.sparkapp.Main 72 | 73 | 74 | 75 | 76 | 77 | com.heroku.sdk 78 | heroku-maven-plugin 79 | 0.4.4 80 | 81 | 1.8 82 | 83 | 84 | java -jar ./target/${project.artifactId}-${project.version}-jar-with-dependencies.jar 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /examples/java/src/main/java/com/fbsamples/delegatedrecovery/sparkapp/Main.java: -------------------------------------------------------------------------------- 1 | // Copyright 2016-present, Facebook, Inc. 2 | // All rights reserved. 3 | // 4 | // This source code is licensed under the license found in the 5 | // LICENSE-examples file in the root directory of this source tree. 6 | package com.fbsamples.delegatedrecovery.sparkapp; 7 | 8 | import com.facebook.delegatedrecovery.AccountProviderConfiguration; 9 | import com.facebook.delegatedrecovery.DelegatedRecoveryConfiguration; 10 | import com.facebook.delegatedrecovery.DelegatedRecoveryUtils; 11 | import com.facebook.delegatedrecovery.RecoveryProviderConfiguration; 12 | import spark.ModelAndView; 13 | import spark.template.mustache.MustacheTemplateEngine; 14 | 15 | import java.util.HashMap; 16 | import java.util.Map; 17 | import java.util.Optional; 18 | 19 | import static spark.Spark.*; 20 | import static spark.debug.DebugScreen.enableDebugScreen; 21 | 22 | /** 23 | * Main class for serving the example Spark application. See 24 | * https://sparkjava.com/ for framework documentation. 25 | */ 26 | public class Main { 27 | 28 | private static String RECOVERY_PROVIDER = "https://www.facebook.com"; 29 | private static String ISSUER_ORIGIN; 30 | 31 | private static AccountProviderConfiguration accountProviderConfig; 32 | private static RecoveryProviderConfiguration recoveryProviderConfig; 33 | 34 | /** 35 | * @return statically cached account provider config 36 | */ 37 | public static AccountProviderConfiguration getAccountProviderConfig() { 38 | return accountProviderConfig; 39 | } 40 | 41 | /** 42 | * @return statically cached recovery provider config for 43 | * https://www.facebook.com 44 | */ 45 | public static RecoveryProviderConfiguration getRecoveryProviderConfig() { 46 | return recoveryProviderConfig; 47 | } 48 | 49 | // static initialization of configuration data 50 | static { 51 | try { 52 | // load some config values from the environment (Heroku-specific) 53 | ISSUER_ORIGIN = new ProcessBuilder().environment().get("ISSUER_ORIGIN"); 54 | String publicKey = new ProcessBuilder().environment().get("RECOVERY_PUBLIC_KEY"); 55 | 56 | // build our configuration object statically 57 | accountProviderConfig = 58 | new AccountProviderConfiguration( 59 | ISSUER_ORIGIN, // our issuer 60 | ISSUER_ORIGIN + Path.Web.SAVE_TOKEN_RETURN, 61 | ISSUER_ORIGIN + Path.Web.RECOVER_ACCOUNT_RETURN, 62 | ISSUER_ORIGIN + Path.Web.PRIVACY_POLICY, 63 | new String[] { publicKey }, 64 | ISSUER_ORIGIN + Path.Web.ICON); 65 | 66 | // pre-fetch and save the Facebook recovery provider configuration 67 | // a real application should examine and respect the Cache-Control: 68 | // max-age values for this data 69 | recoveryProviderConfig = 70 | (RecoveryProviderConfiguration) DelegatedRecoveryUtils.fetchConfiguration( 71 | RECOVERY_PROVIDER, 72 | DelegatedRecoveryConfiguration.ConfigType.RECOVERY_PROVIDER); 73 | 74 | } catch (Exception e) { 75 | e.printStackTrace(); 76 | System.exit(0); 77 | } 78 | } 79 | 80 | public static void main(String[] args) { 81 | 82 | //// 83 | // Spark setup 84 | //// 85 | port(getHerokuAssignedPort()); 86 | staticFiles.location("/static"); 87 | enableDebugScreen(); 88 | 89 | //// 90 | // Filters 91 | //// 92 | 93 | // this app should only be accessed over https and not framed 94 | before((req, res) -> { 95 | res.header("Strict-Transport-Security", "max-age=3600000; includeSubDomains"); 96 | res.header("X-Frame-Options", "DENY"); 97 | if(!req.pathInfo().equals(DelegatedRecoveryConfiguration.CONFIG_PATH)) { 98 | res.header("Cache-Control", "no-store, must-revalidate"); 99 | } 100 | }); 101 | 102 | // redirect http to https if not locally debugging 103 | before((req, res) -> { 104 | String path = Optional.ofNullable(req.pathInfo()).orElse(""); 105 | // X-Forwarded-Proto is the Heroku way to tell if original request used https 106 | if (!Optional.ofNullable(req.headers("X-Forwarded-Proto")).orElse("https").equals("https")) { 107 | if (path.equals(DelegatedRecoveryConfiguration.CONFIG_PATH) || path.equals(Path.Web.RECOVER_ACCOUNT_RETURN)) { 108 | halt(401, "Not available at this scheme. Use https."); 109 | } else { 110 | res.redirect("https://" + req.host() + path 111 | + Optional.ofNullable(req.queryString()).map(queryString -> "?" + queryString).orElse(""), 301); // moved 112 | } // permanently 113 | } 114 | }); 115 | 116 | //// 117 | // Routes 118 | //// 119 | 120 | // account provider configuration 121 | get(DelegatedRecoveryConfiguration.CONFIG_PATH, "application/json", (req, res) -> { 122 | res.header("Cache-Control", "max-age=60"); 123 | return accountProviderConfig.toString(); 124 | }); 125 | 126 | // "login" page at / 127 | get(Path.Web.DEFAULT, (req, res) -> { 128 | Map model = new HashMap(); 129 | model.put("action", Path.Web.SAVE_TOKEN); 130 | model.put("recoverAction", Path.Web.RECOVER_IDENTIFY_ACCOUNT); 131 | return render(model, Path.Template.DEFAULT); 132 | }); 133 | 134 | // save token actions 135 | get(Path.Web.SAVE_TOKEN, SaveTokenController.serveSaveToken); 136 | get(Path.Web.SAVE_TOKEN_RETURN, SaveTokenController.serveSaveTokenReturn); 137 | get(Path.Web.INVALIDATE_TOKEN, SaveTokenController.serveInvalidateToken); 138 | get(Path.Web.RENEW_TOKEN, SaveTokenController.serveRenewToken); 139 | 140 | // recover account actions 141 | get(Path.Web.RECOVER_IDENTIFY_ACCOUNT, RecoverAccountController.serveIdentifyAccount); 142 | post(Path.Web.RECOVER_ACCOUNT_RETURN, RecoverAccountController.serveRecoverAccountReturn); 143 | 144 | // token status callback 145 | post(DelegatedRecoveryConfiguration.TOKEN_STATUS_PATH, (req, res) -> { 146 | String id = req.queryParams("id"); 147 | String status = req.queryParams("status"); 148 | 149 | RecoveryTokenRecord record = RecoveryTokenRecordDao.getTokenRecordById(req.queryParams("id")); 150 | 151 | if (record != null && status != null) { 152 | if (status.equals("save-success")) { 153 | record.setStatus(RecoveryTokenRecord.Status.CONFIRMED); 154 | } else if (status.equals("save-failure") || status.equals("deleted")) { 155 | RecoveryTokenRecordDao.deleteRecordById(id); 156 | } else if (status.equals("token-repudiated")) { 157 | record.setStatus(RecoveryTokenRecord.Status.INVALID); 158 | } 159 | } 160 | res.status(200); 161 | return ""; 162 | }); 163 | 164 | } 165 | 166 | static int getHerokuAssignedPort() { 167 | ProcessBuilder processBuilder = new ProcessBuilder(); 168 | if (processBuilder.environment().get("PORT") != null) { 169 | return Integer.parseInt(processBuilder.environment().get("PORT")); 170 | } 171 | return 4567; // return default port if heroku-port isn't set (i.e. on 172 | // localhost) 173 | } 174 | 175 | /** 176 | * Avoid constantly re-typing new MoustacheTemplateEngine(), new 177 | * ModelAndView() 178 | * 179 | * @param model 180 | * @param templatePath 181 | * @return 182 | */ 183 | public static String render(Map model, String templatePath) { 184 | return new MustacheTemplateEngine().render(new ModelAndView(model, templatePath)); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /examples/java/src/main/java/com/fbsamples/delegatedrecovery/sparkapp/Path.java: -------------------------------------------------------------------------------- 1 | // Copyright 2016-present, Facebook, Inc. 2 | // All rights reserved. 3 | // 4 | // This source code is licensed under the license found in the 5 | // LICENSE-examples file in the root directory of this source tree. 6 | package com.fbsamples.delegatedrecovery.sparkapp; 7 | 8 | /** 9 | * Web and template paths for the example application. 10 | */ 11 | public class Path { 12 | 13 | public static class Web { 14 | public static final String DEFAULT = "/"; 15 | public static final String SAVE_TOKEN = "/home/"; 16 | public static final String SAVE_TOKEN_RETURN = "/save-token-return/"; 17 | public static final String RECOVER_ACCOUNT_RETURN = "/recover-account-return/"; 18 | public static final String ICON = "/icon.png"; 19 | public static final String PRIVACY_POLICY = "/privacy.html"; 20 | public static final String RECOVER_IDENTIFY_ACCOUNT = "/identify-account/"; 21 | public static final String INVALIDATE_TOKEN = "/invalidate/"; 22 | public static final String RENEW_TOKEN = "/renew/"; 23 | } 24 | 25 | public static class Template { 26 | public static final String DEFAULT = "/index.mustache"; 27 | public static final String SAVE_TOKEN = "/save.mustache"; 28 | public static final String INVALIDATE_TOKEN = "/invalidate.mustache"; 29 | public static final String SAVE_TOKEN_SUCCESS = "/save_token_success.mustache"; 30 | public static final String SAVE_TOKEN_FAILURE = "/save_token_failure.mustache"; 31 | public static final String RECOVER_ACCOUNT_SUCCESS = "/recover_account_success.mustache"; 32 | public static final String RECOVER_ACCOUNT_FAILURE = "/recover_account_failure.mustache"; 33 | public static final String IDENTIFY_ACCOUNT = "/identify_account.mustache"; 34 | public static final String RECOVER_ACCOUNT = "/recover_account.mustache"; 35 | public static final String NO_SAVED_TOKEN = "/no_token.mustache"; 36 | public static final String UNKNOWN_TOKEN = "/save_token_unknown.mustache"; 37 | public static final String RENEW_TOKEN = "/renew.mustache"; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/java/src/main/java/com/fbsamples/delegatedrecovery/sparkapp/RecoverAccountController.java: -------------------------------------------------------------------------------- 1 | // Copyright 2016-present, Facebook, Inc. 2 | // All rights reserved. 3 | // 4 | // This source code is licensed under the license found in the 5 | // LICENSE-examples file in the root directory of this source tree. 6 | package com.fbsamples.delegatedrecovery.sparkapp; 7 | 8 | import com.facebook.delegatedrecovery.CountersignedRecoveryToken; 9 | import com.facebook.delegatedrecovery.DelegatedRecoveryConfiguration; 10 | import com.facebook.delegatedrecovery.DelegatedRecoveryUtils; 11 | import com.facebook.delegatedrecovery.RecoveryProviderConfiguration; 12 | import spark.Request; 13 | import spark.Response; 14 | import spark.Route; 15 | 16 | import java.io.PrintWriter; 17 | import java.io.StringWriter; 18 | import java.util.HashMap; 19 | import java.util.HashSet; 20 | import java.util.List; 21 | import java.util.Map; 22 | 23 | /** 24 | * Controller logic for the actions around recovering an account 25 | */ 26 | public class RecoverAccountController { 27 | 28 | // toy implementation of a replay cache for this sample app, data is lost on 29 | // app reload 30 | private static HashSet replayCache = new HashSet(); 31 | 32 | /** 33 | * Identify the account to recover 34 | */ 35 | public static Route serveIdentifyAccount = (Request req, Response res) -> { 36 | String username = req.queryParams("username"); 37 | Map model = new HashMap(); 38 | 39 | if (username == null || username.equals("")) { 40 | model.put("action", Path.Web.RECOVER_IDENTIFY_ACCOUNT); 41 | model.put("facebookRecover", Main.getRecoveryProviderConfig().getRecoverAccount().toString() + "?issuer=" 42 | + Main.getAccountProviderConfig().getIssuer()); 43 | return Main.render(model, Path.Template.IDENTIFY_ACCOUNT); 44 | } else { 45 | List records = RecoveryTokenRecordDao.getSavedTokensForUser(username, 46 | RecoveryTokenRecord.Status.CONFIRMED); 47 | if (records.size() > 0) { 48 | RecoveryTokenRecord record = records.get(0); 49 | model.put("id", record.getId()); 50 | model.put("username", username); 51 | model.put("action", Main.getRecoveryProviderConfig().getRecoverAccount()); 52 | return Main.render(model, Path.Template.RECOVER_ACCOUNT); 53 | } else { 54 | model.put("username", username); 55 | return Main.render(model, Path.Template.NO_SAVED_TOKEN); 56 | } 57 | } 58 | }; 59 | 60 | /** 61 | * Handle an incoming countersigned recovery token and give access to account 62 | * if correct 63 | */ 64 | public static Route serveRecoverAccountReturn = (Request req, Response res) -> { 65 | try { 66 | String encoded = req.queryParams("token"); 67 | if (encoded == null || encoded.equals("")) { 68 | throw new Exception("No recovery token."); 69 | } 70 | 71 | // check relay cache if we've seen this countersigned token before 72 | synchronized (replayCache) { 73 | if (replayCache.contains(encoded)) { 74 | throw new Exception("countersigned token replay detected!"); 75 | } else { 76 | replayCache.add(encoded); 77 | } 78 | } 79 | 80 | String issuer = CountersignedRecoveryToken.extractIssuer(encoded); 81 | RecoveryProviderConfiguration recoveryProviderConfig = Main.getRecoveryProviderConfig(); 82 | 83 | // if the token incoming isn't from Facebook, which we have cached, fetch 84 | // the correct configuration 85 | if (!issuer.equals(recoveryProviderConfig.getIssuer())) { 86 | recoveryProviderConfig = (RecoveryProviderConfiguration) DelegatedRecoveryUtils.fetchConfiguration(issuer, 87 | DelegatedRecoveryConfiguration.ConfigType.RECOVERY_PROVIDER); 88 | } 89 | 90 | // constructing a countersigned token automatically validates the outer 91 | // token 92 | CountersignedRecoveryToken countersignedToken = new CountersignedRecoveryToken(encoded, issuer, 93 | Main.getAccountProviderConfig().getIssuer(), // our service's issuer 94 | // is audience for 95 | // countersigned token 96 | recoveryProviderConfig.getPubKeys(), 60 * 60,// validity period in 97 | // seconds (one hour) 98 | null // no token binding expected 99 | ); 100 | 101 | RecoveryTokenRecord record = RecoveryTokenRecordDao.getTokenRecordByHash(countersignedToken.getInnerTokenHash()); 102 | String expectedUsername = req.queryParams("state"); 103 | 104 | if (record == null) { 105 | throw new Exception("No record of this token. Perhaps you restarted this app since it was issued?"); 106 | } else if (record.getStatus() != RecoveryTokenRecord.Status.CONFIRMED) { 107 | throw new Exception("The recovery token from this app wasn't market as valid."); 108 | } else if (expectedUsername != null && !expectedUsername.equals("") 109 | && !expectedUsername.equals(record.getUsername())) { 110 | throw new Exception("The recovery token from this app was not for " + expectedUsername); 111 | } else { 112 | Map model = new HashMap(); 113 | model.put("username", record.getUsername()); 114 | return Main.render(model, Path.Template.RECOVER_ACCOUNT_SUCCESS); 115 | } 116 | } catch (Exception e) { 117 | Map model = new HashMap(); 118 | model.put("exception", e.getMessage()); 119 | StringWriter sw = new StringWriter(); 120 | e.printStackTrace(new PrintWriter(sw)); 121 | model.put("stackTrace", sw.toString()); 122 | return Main.render(model, Path.Template.RECOVER_ACCOUNT_FAILURE); 123 | } 124 | }; 125 | 126 | } 127 | -------------------------------------------------------------------------------- /examples/java/src/main/java/com/fbsamples/delegatedrecovery/sparkapp/RecoveryTokenRecord.java: -------------------------------------------------------------------------------- 1 | // Copyright 2016-present, Facebook, Inc. 2 | // All rights reserved. 3 | // 4 | // This source code is licensed under the license found in the 5 | // LICENSE-examples file in the root directory of this source tree. 6 | package com.fbsamples.delegatedrecovery.sparkapp; 7 | 8 | /** 9 | * Simple representation of a recovery token that is saved at a Recovery 10 | * Provider. Remembering the id, hash, issuer and username avoids the necessity 11 | * to re-validate the inner token or manage long-term encryption keys at the 12 | * Account Provider 13 | */ 14 | public class RecoveryTokenRecord { 15 | 16 | public enum Status { 17 | PROVISIONAL, CONFIRMED, INVALID 18 | }; 19 | 20 | private Status status; 21 | private String username; 22 | private String id; 23 | private String hash; 24 | private String issuer; 25 | 26 | public RecoveryTokenRecord(String username, String id, String issuer, String hash, Status status) { 27 | this.username = username; 28 | this.issuer = issuer; 29 | this.id = id; 30 | this.hash = hash; 31 | this.status = status; 32 | } 33 | 34 | public String getId() { 35 | return id; 36 | } 37 | 38 | public String getIssuer() { 39 | return issuer; 40 | } 41 | 42 | public Status getStatus() { 43 | return status; 44 | } 45 | 46 | public String getUsername() { 47 | return username; 48 | } 49 | 50 | public String getHash() { 51 | return hash; 52 | } 53 | 54 | public void setStatus(Status status) { 55 | this.status = status; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /examples/java/src/main/java/com/fbsamples/delegatedrecovery/sparkapp/RecoveryTokenRecordDao.java: -------------------------------------------------------------------------------- 1 | // Copyright 2016-present, Facebook, Inc. 2 | // All rights reserved. 3 | // 4 | // This source code is licensed under the license found in the 5 | // LICENSE-examples file in the root directory of this source tree. 6 | package com.fbsamples.delegatedrecovery.sparkapp; 7 | 8 | import java.util.ArrayList; 9 | import java.util.List; 10 | import java.util.stream.Collectors; 11 | 12 | /** 13 | * Fake DAO pattern object to manage RecoveryTokenRecord objects. This just uses 14 | * a static ArrayList as a backing store and state is lost when the app is 15 | * restarted. 16 | */ 17 | public class RecoveryTokenRecordDao { 18 | 19 | static ArrayList records = new ArrayList(); 20 | 21 | public static List getSavedTokensForUser(String username) { 22 | return records.stream().filter(record -> record.getUsername().equals(username)).collect(Collectors.toList()); 23 | } 24 | 25 | public static List getSavedTokensForUser(String username, RecoveryTokenRecord.Status status) { 26 | return records.stream().filter(record -> record.getUsername().equals(username) && record.getStatus() == status) 27 | .collect(Collectors.toList()); 28 | } 29 | 30 | public static RecoveryTokenRecord getTokenRecordById(String id) { 31 | return records.stream().filter(record -> record.getId().equals(id)).findFirst().orElse(null); 32 | } 33 | 34 | public static RecoveryTokenRecord getTokenRecordByHash(String hash) { 35 | return records.stream().filter(record -> record.getHash().equals(hash)).findFirst().orElse(null); 36 | } 37 | 38 | public static void addRecord(RecoveryTokenRecord record) { 39 | records.add(record); 40 | } 41 | 42 | public static void deleteRecordById(String id) { 43 | records.removeIf(record -> record.getId().equals(id)); 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /examples/java/src/main/java/com/fbsamples/delegatedrecovery/sparkapp/SaveTokenController.java: -------------------------------------------------------------------------------- 1 | // Copyright 2016-present, Facebook, Inc. 2 | // All rights reserved. 3 | // 4 | // This source code is licensed under the license found in the 5 | // LICENSE-examples file in the root directory of this source tree. 6 | package com.fbsamples.delegatedrecovery.sparkapp; 7 | 8 | import com.facebook.delegatedrecovery.DelegatedRecoveryUtils; 9 | import com.facebook.delegatedrecovery.InvalidOriginException; 10 | import com.facebook.delegatedrecovery.RecoveryToken; 11 | import spark.Request; 12 | import spark.Response; 13 | import spark.Route; 14 | 15 | import java.io.IOException; 16 | import java.security.KeyPair; 17 | import java.security.interfaces.ECPrivateKey; 18 | import java.util.Base64; 19 | import java.util.HashMap; 20 | import java.util.List; 21 | import java.util.Map; 22 | 23 | /** 24 | * Controller logic for the actions around saving a recovery token 25 | */ 26 | public class SaveTokenController { 27 | 28 | private static ECPrivateKey privateKey; 29 | 30 | /* 31 | * Initialize the private key for token signing 32 | */ 33 | static { 34 | try { 35 | String keystring = new ProcessBuilder().environment().get("RECOVERY_PRIVATE_KEY"); 36 | KeyPair keypair = DelegatedRecoveryUtils.keyPairFromPEMString(keystring); 37 | privateKey = (ECPrivateKey) keypair.getPrivate(); 38 | } catch (Exception e) { 39 | e.printStackTrace(); 40 | System.exit(0); 41 | } 42 | } 43 | 44 | /** 45 | * Landing page when returning from saving a token at Facebook, updates the 46 | * local records of token status in the RecoveryTokenRecordDao 47 | */ 48 | public static Route serveSaveTokenReturn = (Request req, Response res) -> { 49 | Map model = new HashMap(); 50 | String state = req.queryParams("state"); 51 | String[] ids = state.split(",", 2); 52 | RecoveryTokenRecord record = RecoveryTokenRecordDao.getTokenRecordById(ids[0]); 53 | RecoveryTokenRecord obsoletedRecord = 54 | ids.length > 1 ? RecoveryTokenRecordDao.getTokenRecordById(ids[1]) : null; 55 | 56 | if (record == null) { 57 | model.put("action", Path.Web.DEFAULT); 58 | model.put("id", ids[0]); 59 | return Main.render(model, Path.Template.UNKNOWN_TOKEN); 60 | } 61 | 62 | if (req.queryParams("status").equals("save-success")) { 63 | record.setStatus(RecoveryTokenRecord.Status.CONFIRMED); 64 | if (obsoletedRecord != null) { 65 | obsoletedRecord.setStatus(RecoveryTokenRecord.Status.INVALID); 66 | } 67 | model.put("username", record.getUsername()); 68 | return Main.render(model, Path.Template.SAVE_TOKEN_SUCCESS); 69 | } else { 70 | RecoveryTokenRecordDao.deleteRecordById(ids[0]); 71 | model.put("username", record.getUsername()); 72 | model.put("homeAction", Path.Web.SAVE_TOKEN); 73 | return Main.render(model, Path.Template.SAVE_TOKEN_FAILURE); 74 | } 75 | }; 76 | 77 | private static RecoveryToken createNewToken(String username) throws InvalidOriginException, IOException{ 78 | byte[] id = DelegatedRecoveryUtils.newTokenID(); 79 | String ourOrigin = Main.getAccountProviderConfig().getIssuer(); 80 | String facebookOrigin = Main.getRecoveryProviderConfig().getIssuer(); 81 | RecoveryToken token = new RecoveryToken(privateKey, // signing key 82 | id, // token id 83 | RecoveryToken.STATUS_REQUESTED_FLAG, // get lifecycle callbacks 84 | ourOrigin, 85 | facebookOrigin, 86 | new byte[0], // no data 87 | new byte[0]); // no binding 88 | 89 | String stringID = DelegatedRecoveryUtils.encodeHex(token.getId()); 90 | String encoded = token.getEncoded(); 91 | 92 | // keep a record of tokens we've created for this username 93 | // (note, in this sample app this record does not survive service restart) 94 | RecoveryTokenRecordDao.addRecord( 95 | new RecoveryTokenRecord( 96 | username, 97 | stringID, 98 | token.getAudience(), 99 | DelegatedRecoveryUtils.sha256(Base64.getDecoder().decode(encoded)), 100 | RecoveryTokenRecord.Status.PROVISIONAL)); 101 | 102 | return token; 103 | } 104 | 105 | /** 106 | * Landing page of the app. Create and prompt to save a token at Facebook if 107 | * none found in the RecoveryTokenRecordDao for this username, or give option 108 | * to invalidate locally the token if one exists. 109 | */ 110 | public static Route serveSaveToken = (Request req, Response res) -> { 111 | String username = req.queryParams("username"); 112 | if (username == null || username.equals("")) { 113 | res.redirect(Path.Web.DEFAULT); 114 | return ""; 115 | } 116 | 117 | // does this username already have a recovery token saved? 118 | List savedTokens = RecoveryTokenRecordDao.getSavedTokensForUser(username, 119 | RecoveryTokenRecord.Status.CONFIRMED); 120 | 121 | Map model = new HashMap(); 122 | 123 | if (savedTokens.isEmpty()) { // user has no saved tokens yet 124 | RecoveryToken token = createNewToken(username); 125 | String stringID = DelegatedRecoveryUtils.encodeHex(token.getId()); 126 | String encoded = token.getEncoded(); 127 | model.put("encoded-token", encoded); 128 | model.put("username", username); 129 | model.put("save-token", Main.getRecoveryProviderConfig().getSaveToken()); 130 | model.put("state", stringID); 131 | return Main.render(model, Path.Template.SAVE_TOKEN); 132 | } else { 133 | model.put("renew-action", Path.Web.RENEW_TOKEN); 134 | model.put("id", savedTokens.get(0).getId()); 135 | model.put("username", username); 136 | return Main.render(model, Path.Template.INVALIDATE_TOKEN); 137 | } 138 | }; 139 | 140 | /** 141 | * Generate a new token to replace the old one and post recovery provider. 142 | */ 143 | public static Route serveRenewToken = (Request req, Response res) -> { 144 | String oldId = req.queryParams("id"); 145 | String username = req.queryParams("username"); 146 | RecoveryToken token = createNewToken(username); 147 | String newId = DelegatedRecoveryUtils.encodeHex(token.getId()); 148 | 149 | Map model = new HashMap(); 150 | model.put("encoded-token", token.getEncoded()); 151 | model.put("username", username); 152 | model.put("renew-action", Main.getRecoveryProviderConfig().getSaveToken()); 153 | model.put("state", newId + "," + oldId); 154 | model.put("obsoletes", oldId); 155 | return Main.render(model, Path.Template.RENEW_TOKEN); 156 | }; 157 | 158 | /** 159 | * Locally mark a token as no longer valid. 160 | */ 161 | public static Route serveInvalidateToken = (Request req, Response res) -> { 162 | String id = req.queryParams("id"); 163 | String username = req.queryParams("username"); 164 | RecoveryTokenRecord record = RecoveryTokenRecordDao.getTokenRecordById(id); 165 | if (record != null) { 166 | record.setStatus(RecoveryTokenRecord.Status.INVALID); 167 | } 168 | res.redirect(Path.Web.SAVE_TOKEN + "?username=" + username); 169 | return ""; 170 | }; 171 | } 172 | -------------------------------------------------------------------------------- /examples/java/src/main/resources/static/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/facebookarchive/DelegatedRecoveryReferenceImplementation/9cf7c1cabddac828c854aa3f8d697e37cd2e33b0/examples/java/src/main/resources/static/icon.png -------------------------------------------------------------------------------- /examples/java/src/main/resources/static/privacy.html: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | Sample Privacy Policy 11 | 12 | 13 | 14 | Example Application Privacy Policy 15 | ================================== 16 | This examples application is for non-commercial testing and evaluation 17 | purposes only. It does not retain permanent records but should not be used 18 | with any sensitive data. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 23 | FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 24 | ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 25 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 26 | 27 | 28 | -------------------------------------------------------------------------------- /examples/java/src/main/resources/static/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2016-present, Facebook, Inc. 3 | All rights reserved. 4 | 5 | This source code is licensed under the license found in the 6 | LICENSE-examples file in the root directory of this source tree. 7 | */ 8 | body { 9 | background-color: white; 10 | border: 3px solid black; 11 | border-radius: 1.5em; 12 | box-shadow: 10px 10px 5px 0px rgba(0,0,0,0.5); 13 | font-family: 'Roboto', Helvetica, sans-serif; 14 | font-size: 16px; 15 | font-weight: 100; 16 | height: 100%; 17 | margin-top: 1.5em; 18 | margin-left: 1.5em; 19 | max-height: 550px; 20 | padding: 2em; 21 | text-align: center; 22 | width: 300px; 23 | } 24 | 25 | 26 | #title { 27 | font-size: 20px; 28 | font-weight: 200; 29 | } 30 | 31 | #emoji { 32 | font-size: 100px; 33 | } 34 | 35 | #message { 36 | padding-bottom: .3em; 37 | padding-top: .3em; 38 | } 39 | 40 | input { 41 | margin-left: auto; 42 | margin-right: auto; 43 | margin-bottom: .5em; 44 | padding: .5em; 45 | width: 160px; 46 | } 47 | 48 | .button { 49 | border: 2px solid lightblue; 50 | background-color: blue; 51 | color: white; 52 | display: block; 53 | font-family: 'Roboto', Helvetica, sans-serif; 54 | font-size: 16px; 55 | font-weight: 100; 56 | text-decoration: none; 57 | text-shadow: 1px 1px gray; 58 | } 59 | 60 | .button:hover, .button:active { 61 | box-shadow: 1px 1px 1px lightblue, -1px -1px 1px lightblue; 62 | } 63 | -------------------------------------------------------------------------------- /examples/java/src/main/resources/templates/identify_account.mustache: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | Identify Account 13 | 14 | 15 |
Identify Account
16 |
🎭
17 |
18 | Enter the username of the account you wish to recover: 19 |
20 |
21 | 22 | 23 |
24 |
If you don't remember your username, you can try to use your Facebook account. 25 | 26 | -------------------------------------------------------------------------------- /examples/java/src/main/resources/templates/index.mustache: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | Delegated Recovery Sample App 13 | 14 | 15 |
Delegated Recovery Sample App
16 |
🔐
17 |
18 | Enter a username to login. 19 |
20 |
21 | 22 | 23 |
24 | Forgot Password 25 | 26 | -------------------------------------------------------------------------------- /examples/java/src/main/resources/templates/invalidate.mustache: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | Welcome, {{username}} 13 | 14 | 15 |
Welcome, {{username}}
16 |
🔖
17 |
18 | You can recover with Facebook. 19 |
20 |
21 | 22 | 23 | 24 |
25 |
26 | 27 | 28 | 29 |
30 | 31 | 32 | -------------------------------------------------------------------------------- /examples/java/src/main/resources/templates/no_token.mustache: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | Recover {{username}}'s Account 13 | 14 | 15 |
Recover {{username}}'s Account
16 |
😞
17 |
18 | This username hasn't been set up for recovery, or you restarted the app or 19 | deleted your tokens. 20 |
21 | 22 | -------------------------------------------------------------------------------- /examples/java/src/main/resources/templates/recover_account.mustache: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | Recover {{username}}'s Account 13 | 14 | 15 |
Recover {{username}}'s Account
16 |
🔑
17 |
18 | You can recover with Facebook. 19 |
20 |
21 | 22 | 23 | 24 |
25 | 26 | -------------------------------------------------------------------------------- /examples/java/src/main/resources/templates/recover_account_failure.mustache: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | Something went wrong. 13 | 14 | 15 | 16 |
Something went wrong. We could not recover your account.
17 |
💣
18 |
19 | {{exception}} 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /examples/java/src/main/resources/templates/recover_account_success.mustache: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | Welcome back, {{username}}! 13 | 14 | 15 | 16 |
Welcome back, {{username}}!
17 |
🌟
18 |
19 | You successfully recovered access to your account. 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /examples/java/src/main/resources/templates/renew.mustache: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | Welcome, {{username}} 13 | 14 | 15 |
Welcome, {{username}}
16 |
🔖
17 |
18 | Click the button below to confirm renewing your token: 19 |
20 |
21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | -------------------------------------------------------------------------------- /examples/java/src/main/resources/templates/save.mustache: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | Welcome, {{username}} 13 | 14 | 15 | 16 |
Welcome, {{username}}
17 |
😨
18 |
19 | You don't have a way to recover if you forget your password! 20 |
21 |
22 | 23 | 24 | 25 | 26 |
27 | 28 | -------------------------------------------------------------------------------- /examples/java/src/main/resources/templates/save_token_failure.mustache: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | That didn't work. 13 | 14 | 15 | 16 |
That didn't work.
17 |
😕
18 |
19 | It looks like you weren't successful in setting up a recovery method. 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /examples/java/src/main/resources/templates/save_token_success.mustache: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | Thanks, {{username}} 13 | 14 | 15 | 16 |
Thanks, {{username}}
17 |
🔗
18 |
19 | You can use Facebook to restore access if you ever forget your password. 20 |
21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /examples/java/src/main/resources/templates/save_token_unknown.mustache: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | Unknown Token 13 | 14 | 15 |
Unknown Token
16 |
😵
17 |
18 | Something went wrong. We don't have a record of the token you just saved. 19 | Maybe this app was restarted while you were at Facebook? 20 |
21 | Start Over 22 | 23 | -------------------------------------------------------------------------------- /examples/nodejs/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "rules": { 8 | "indent": [ 9 | "error", 10 | 4 11 | ], 12 | "linebreak-style": [ 13 | "error", 14 | "unix" 15 | ], 16 | "quotes": [ 17 | "error", 18 | "single" 19 | ], 20 | "semi": [ 21 | "error", 22 | "always" 23 | ], 24 | "no-console": [ 25 | 0 26 | ], 27 | "comma-dangle": [ 28 | "error", 29 | "always-multiline" 30 | ] 31 | } 32 | } -------------------------------------------------------------------------------- /examples/nodejs/Procfile: -------------------------------------------------------------------------------- 1 | web: node index.js 2 | -------------------------------------------------------------------------------- /examples/nodejs/README.md: -------------------------------------------------------------------------------- 1 | # Delegated Account Recovery Example Application for Node.js 2 | 3 | The "examples/nodejs" directory of [https://github.com/facebook/DelegatedRecoveryReferenceImplementation](https://github.com/facebook/DelegatedRecoveryReferenceImplementation) provides an example web application and library for using the Delegated Account 4 | Recovery protocol documented at [https://github.com/facebook/DelegatedRecoveryReferenceImplementation](https://github.com/facebook/DelegatedRecoveryReferenceImplementation) 5 | 6 | 7 | ## Usage 8 | This is a `Node.js` application built with the `Express` framework. It is 9 | packaged for deployment on `Heroku`, but should be easily adaptable to any `Node.js` environment. 10 | 11 | `index.js` contains the example application. This application is, 12 | apart from some cryptographic keys, stateless, and only demonstrates the protocol flows 13 | without creating actual user accounts. 14 | 15 | This code is for example purposes only, to demonstrate the concepts of Delegated Account Recovery. Because the application does not persist any state, you will not be able to recover "accounts" across resets of the runtime, including when Herkou puts the application to sleep and restores it. 16 | 17 | ## Dependencies 18 | 19 | The example application is written in 20 | [ES2015](https://babeljs.io/docs/learn-es2015/) for 21 | [Node JS](https://nodejs.org/en/) >= 6.10.0 22 | 23 | It uses the `delegated-account-recovery` module to implement core features of Delegated Account Recovery. 24 | 25 | The example application is built with the [Express](https://expressjs.com/) 26 | framework. The application is built to run on the [Heroku](https://www.heroku.com/) 27 | cloud application platform, but has only a few lines of Herkou-specific code, 28 | to manage configuration of application secrets and handle Heroku's idiosyncracies 29 | in how https is routed. The application should be easily adapted to any Node.js hosting 30 | environment. Refer to the documentation for your specific environment to configure https 31 | and secure storage for application secrets. 32 | 33 | The example application requires the following additional `NPM` modules: 34 | 35 | * [delegated-account-recovery](https://www.npmjs.com/package/delegated-account-recovery) 36 | * [express](https://www.npmjs.com/package/express) 37 | * [express-mustache](https://www.npmjs.com/package/express-mustache) 38 | * [body-parser](https://www.npmjs.com/package/body-parser) 39 | 40 | Following the step-by-step tutorial included in the example application will require 41 | 42 | * a bash command line environment with git, openssl, and curl available 43 | * a [Heroku](https://www.heroku.com/) account 44 | * the [Heroku toolbelt](https://devcenter.heroku.com/articles/getting-started-with-nodejs#set-up) installed for working with Node.js applications 45 | 46 | ## Installation 47 | Begin by forking the repository. In the top right corner of [the repository home page on GitHub](https://github.com/facebook/DelegatedRecoveryReferenceImplementation), click **Fork** ![Fork](https://help.github.com/assets/images/help/repository/fork_button.jpg) 48 | 49 | Now, in your bash command line, get a copy of the forked repository. 50 | ```bash 51 | $ git clone https://github.com/{your-github-username}/DelegatedRecoveryReferenceImplementation 52 | ``` 53 | 54 | Change to the root directory of your cloned repository 55 | ```bash 56 | $ cd DelegatedRecoveryReferenceImplementation 57 | ``` 58 | 59 | Edit the `examples/nodejs/app.json` and `examples/nodejs/package.json` files and make sure the "repository" properties point to your fork of the application. 60 | 61 | **These steps must be run from the root directory of your repository clone.** 62 | 63 | 1. First, commit your updates to app.json and package.json 64 | 1. Next, create a heroku app. The results of this command will tell you your app name. 65 | 1. Push the subtree containing the example app to the `heroku` git remote created by step 2. 66 | 67 | ```bash 68 | $ git commit -am "update app.json and package.json" 69 | $ heroku create 70 | ``` 71 | 72 | Because we only want to push the example app, not the entire reference implementation repository, use the following command to deploy: 73 | ``` 74 | $ git subtree push --prefix examples/nodejs heroku master 75 | ``` 76 | 77 | Note, if you use `git commit --amend` as part of your develompent process, in order to re-deploy an amended commit to the subtree you will need to use the following command line: 78 | ``` 79 | $ git push heroku `git subtree split --prefix examples/nodejs`:master --force 80 | ``` 81 | 82 | Ensure that at least one instance of the app is running: 83 | ```bash 84 | $ heroku ps:scale web=1 85 | ``` 86 | 87 | Next, you need to set some config variables for the application. You must 88 | have a recent build of openssl to complete this step. 89 | 90 | First, you need to create the assymetric key pair for signing recovery tokens. 91 | ```bash 92 | $ openssl ecparam -name prime256v1 -genkey -noout -out prime256v1-key.pem 93 | $ openssl ec -in prime256v1-key.pem -pubout -out prime256v1-pub.pem 94 | ``` 95 | 96 | Make sure you don't check the secret keys into your source control. 97 | (it's fine to check in the public key if you want) 98 | ```bash 99 | $ echo "*.pem" >> .gitignore 100 | ``` 101 | 102 | And now we'll strip the PEM files down to unadorned, single-line base64 103 | and set them as config variables. 104 | ```bash 105 | $ heroku config:set RECOVERY_PRIVATE_KEY=`perl -p -e 's/\R//g; s/-----[\w\s]+-----//' prime256v1-key.pem` 106 | $ heroku config:set RECOVERY_PUBLIC_KEY=`perl -p -e 's/\R//g; s/-----[\w\s]+-----//' prime256v1-pub.pem` 107 | ``` 108 | 109 | Now, set the value your application needs to report as its `issuer` in the Delegated Account Recovery configuration: (note that not trailing slash is allowed) 110 | ```bash 111 | $ heroku config:set ISSUER_ORIGIN="https://{your-app-name}.herokuapp.com" 112 | ``` 113 | 114 | You can see your current configuration using: 115 | ```bash 116 | $ heroku config 117 | ``` 118 | 119 | Check that your configuration is working in the application: 120 | ```bash 121 | $ curl https://{your-app-name}.herokuapp.com/.well-known/delegated-account-recovery/configuration 122 | ``` 123 | 124 | You should get a JSON file that lists your public key as the first entry in the 125 | array that is the value of the key `tokensign-pubkeys-secp256r1` 126 | 127 | You can try the application itself by running: 128 | 129 | ```bash 130 | $ heroku open 131 | ``` 132 | 133 | During the closed beta, you will only be able to use the sample applications when logging in to Facebook with a whitehat test account. [Create and manage test accounts here](https://www.facebook.com/whitehat/accounts). 134 | -------------------------------------------------------------------------------- /examples/nodejs/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Delegated Recovery Example App", 3 | "description": "A Heroku hostable Node.js sample app implementing Delegated Account Recovery account with Facebook", 4 | "repository": "https://github.com/facebook/DelegatedRecoveryReferenceImplementation", 5 | "logo": "http://node-js-sample.herokuapp.com/node.svg", 6 | "keywords": ["node", "express"], 7 | "image": "heroku/nodejs" 8 | } 9 | -------------------------------------------------------------------------------- /examples/nodejs/index.js: -------------------------------------------------------------------------------- 1 | // Copyright 2016-present, Facebook, Inc. 2 | // All rights reserved. 3 | // 4 | // This source code is licensed under the license found in the 5 | // LICENSE-examples file in the root directory of this source tree. 6 | 'use strict'; 7 | const express = require('express'); 8 | const crypto = require('crypto'); 9 | const bodyParser = require('body-parser'); 10 | const mustacheExpress = require('express-mustache'); 11 | const delegatedRecoverySDK = require('delegated-account-recovery'); 12 | const RecoveryToken = delegatedRecoverySDK.RecoveryToken; 13 | const CountersignedToken = delegatedRecoverySDK.CountersignedToken; 14 | const path = require('./path.js'); 15 | 16 | //// 17 | // Heroku-specific config management. Update for your own deployment strategy. 18 | //// 19 | const recoveryPrivKey = process.env.RECOVERY_PRIVATE_KEY; 20 | const recoveryPubKey = process.env.RECOVERY_PUBLIC_KEY; 21 | const issuerOrigin = process.env.ISSUER_ORIGIN; 22 | 23 | if (recoveryPrivKey === undefined || recoveryPubKey === undefined || issuerOrigin === undefined) { 24 | console.error('Necessary environment variables are not defined.'); 25 | process.exit(1); 26 | } 27 | const recoveryProvider = 'https://www.facebook.com'; 28 | 29 | let cachedRecoveryProviderConfig = null; 30 | 31 | function recoveryProviderConfig() { 32 | return new Promise((resolve, reject) => { 33 | if (cachedRecoveryProviderConfig === null) { 34 | delegatedRecoverySDK.fetchConfiguration(recoveryProvider).then( 35 | (config) => { 36 | cachedRecoveryProviderConfig = config; 37 | resolve(config); 38 | }, (e) => { 39 | reject(e); 40 | }); 41 | } else { 42 | resolve(cachedRecoveryProviderConfig); 43 | } 44 | }); 45 | } 46 | 47 | // app-specific record keeping 48 | const tokenRecords = []; 49 | const recordStatus = { 50 | provisional: 'provisional', 51 | confirmed: 'confirmed', 52 | invalid: 'invalid', 53 | }; 54 | 55 | function createNewToken(username, config) { 56 | const id = crypto.randomBytes(16); 57 | const token = new RecoveryToken( 58 | recoveryPrivKey, 59 | id, 60 | RecoveryToken.STATUS_REQUESTED_FLAG, 61 | issuerOrigin, 62 | config.issuer, 63 | new Date().toISOString(), 64 | Buffer.alloc(0), 65 | Buffer.alloc(0)); 66 | 67 | tokenRecords.push({ 68 | status: recordStatus.provisional, 69 | username: username, 70 | id: id.toString('hex'), 71 | issuer: config.issuer, 72 | hash: delegatedRecoverySDK.sha256(new Buffer(token.encoded, 'base64')), 73 | }); 74 | 75 | return token; 76 | } 77 | 78 | const app = express(); 79 | app.set('port', (process.env.PORT || 5000)); 80 | app.use(express.static(__dirname + '/static')); 81 | 82 | // Register '.mustache' extension with The Mustache Express 83 | app.engine('mustache', mustacheExpress.create()); 84 | app.set('view engine', 'mustache'); 85 | app.set('views', __dirname + '/static/templates'); 86 | 87 | app.use(bodyParser.urlencoded({ 88 | extended: false, 89 | })); 90 | 91 | //// 92 | // Set up delegated recovery middleware 93 | //// 94 | 95 | app.use(delegatedRecoverySDK.middleware({ 96 | "issuer": issuerOrigin, 97 | "save-token-return": path.web.saveTokenReturn, 98 | "recover-account-return": path.web.recoverAccountReturn, 99 | "privacy-policy": path.web.privacyPolicy, 100 | "publicKeys": [recoveryPubKey], 101 | "icon-152px": path.web.icon, 102 | "config-max-age": 6000, 103 | })); 104 | 105 | //// 106 | // filters 107 | //// 108 | app.all('*', (req, res, next) => { 109 | // this app should only be accessed over https and not framed 110 | res.set('Strict-Transport-Security', 'max-age=3600000; includeSubDomains'); 111 | res.set('X-Frame-Options', 'DENY'); 112 | if (req.path !== delegatedRecoverySDK.CONFIG_PATH) { 113 | res.set('Cache-Control', 'no-store, must-revalidate'); 114 | } 115 | 116 | // X-Forwarded-Proto is the Heroku-specific way to tell if original 117 | // request used https 118 | const forwardedProto = req.get('X-Forwarded-Proto'); 119 | if (req.hostname !== 'localhost' && forwardedProto !== null && forwardedProto !== 'https') { 120 | // sensitive data endpoints used by APIs should not automatically redirect 121 | if (req.path === delegatedRecoverySDK.CONFIG_PATH || 122 | req.path === path.web.recoverAccountReturn) { 123 | res.send(401, 'Not available at this scheme. Use https.\n'); 124 | } else { 125 | res.redirect('https://' + req.hostname + req.path + '?' + req.query); 126 | } 127 | } else { 128 | next(); 129 | } 130 | }); 131 | 132 | //// 133 | // Routes 134 | //// 135 | 136 | app.get(path.web.default, (req, res) => { 137 | res.render(path.template.default, { 138 | "action": path.web.saveToken, 139 | "recoverAction": path.web.recoverIdentifyAccount, 140 | }); 141 | }); 142 | 143 | app.get(path.web.saveToken, (req, res) => { 144 | const username = req.query.username; 145 | if (username === null) { 146 | res.redirect(path.template.default); 147 | } else { 148 | const tokenRecord = tokenRecords.find((record) => { 149 | return (record.username === username && record.status === recordStatus.confirmed); 150 | }); 151 | 152 | if (tokenRecord === undefined) { 153 | recoveryProviderConfig().then((config) => { 154 | const token = createNewToken(username, config); 155 | res.render(path.template.saveToken, { 156 | "encoded-token": token.encoded, 157 | "username": username, 158 | "state": token.id.toString('hex'), 159 | "save-token": config['save-token'], 160 | }); 161 | }, (e) => { 162 | res.send(500, e.message); 163 | }); 164 | } else { 165 | res.render(path.template.invalidateToken, { 166 | "action": path.web.invalidateToken, 167 | "renew-action": path.web.renewToken, 168 | "id": tokenRecord.id, 169 | "username": username, 170 | }); 171 | } 172 | } 173 | }); 174 | 175 | app.get(path.web.saveTokenReturn, (req, res) => { 176 | const state = req.query.state; 177 | const ids = state.split(',', 2); 178 | const tokenRecord = tokenRecords.find((record) => record.id === ids[0]); 179 | 180 | let obsoletedRecord = null; 181 | 182 | if (ids.length > 1) { 183 | obsoletedRecord = tokenRecords.find((record) => { 184 | return (record.id === ids[1] && record.status === recordStatus.confirmed); 185 | }); 186 | } 187 | 188 | if (tokenRecord === undefined) { 189 | res.render(path.template.unknownToken, { 190 | "action": path.web.default, 191 | }); 192 | } else if (req.query.status === 'save-success') { 193 | tokenRecord.status = recordStatus.confirmed; 194 | if (obsoletedRecord !== null) { 195 | obsoletedRecord.status = recordStatus.invalid; 196 | } 197 | res.render(path.template.saveTokenSuccess, { 198 | "username": tokenRecord.username, 199 | }); 200 | } else { 201 | tokenRecords.splice(tokenRecords.findIndex((record) => record.id === ids[0]), 1); 202 | res.render(path.template.saveTokenFailure, { 203 | "username": tokenRecord.username, 204 | "homeAction": path.web.saveToken, 205 | }); 206 | } 207 | }); 208 | 209 | app.get(path.web.invalidateToken, (req, res) => { 210 | const id = req.query.id; 211 | const username = req.query.username; 212 | const tokenRecord = tokenRecords.find((record) => record.id === id); 213 | if (tokenRecord !== undefined) { 214 | tokenRecord.status = recordStatus.invalid; 215 | } 216 | res.redirect(path.web.saveToken + '?username=' + username); 217 | }); 218 | 219 | app.get(path.web.recoverIdentifyAccount, (req, res) => { 220 | recoveryProviderConfig().then((config) => { 221 | const username = req.query.username; 222 | 223 | if (username === null || username === '') { 224 | res.render(path.template.identifyAccount, { 225 | "action": path.web.recoverIdentifyAccount, 226 | "facebookRecover": config['recover-account'] + '?issuer=' + issuerOrigin, 227 | }); 228 | } else { 229 | const record = tokenRecords.find((record) => { 230 | return record.username === username && record.status === recordStatus.confirmed; 231 | }); 232 | 233 | if (record !== undefined) { 234 | res.render(path.template.recoverAccount, { 235 | "id": record.id, 236 | "username": username, 237 | "action": config['recover-account'], 238 | }); 239 | } else { 240 | res.render(path.template.noSavedToken, { 241 | "username": username, 242 | }); 243 | } 244 | } 245 | }, (e) => { 246 | res.send(500, e); 247 | }); 248 | }); 249 | 250 | app.get(path.web.renewToken, (req, res) => { 251 | const obsoleteId = req.query.id; 252 | const username = req.query.username; 253 | 254 | recoveryProviderConfig().then((config) => { 255 | const token = createNewToken(username, config); 256 | res.render(path.template.renewToken, { 257 | "encoded-token": token.encoded, 258 | "username": username, 259 | "renew-action": config['save-token'], 260 | "state": token.id.toString('hex') + "," + obsoleteId, 261 | "obsoletes": obsoleteId, 262 | }); 263 | }, (e) => { 264 | res.send(500, e.message); 265 | }); 266 | }); 267 | 268 | const replayCache = []; 269 | 270 | app.post(path.web.recoverAccountReturn, (req, res) => { 271 | let errorFlag = false; 272 | 273 | const errorFunction = (message) => { 274 | errorFlag = true; 275 | res.render(path.template.recoverAccountFailure, { 276 | "exception": message, 277 | }); 278 | }; 279 | 280 | const token = req.body.token; 281 | if (token === null || token === '') { 282 | errorFunction('No token.'); 283 | } 284 | 285 | if (replayCache.find((item) => item === token) !== undefined) { 286 | errorFunction('Countersigned token replay detected!'); 287 | } else { 288 | replayCache.push(token); 289 | } 290 | 291 | const issuer = delegatedRecoverySDK.extractIssuer(token); 292 | 293 | // if multiple issuers were supported, would fetch config here, but 294 | // this sample app only uses Facebook with a statically cached config 295 | recoveryProviderConfig().then((config) => { 296 | if (issuer !== config.issuer) { 297 | errorFunction('Countersigned token issuer invalid: ' + issuer); 298 | } 299 | let countersignedToken = null; 300 | try { 301 | countersignedToken = CountersignedToken.fromSerialized( 302 | new Buffer(token, 'base64'), 303 | issuer, 304 | issuerOrigin, 305 | 60 /*sec*/ * 60 /*min*/ , // 1 hour clock skew 306 | Buffer.alloc(0), 307 | config['countersign-pubkeys-secp256r1'] 308 | ); 309 | } catch (e) { 310 | errorFunction(e); 311 | } 312 | 313 | if (countersignedToken !== null) { 314 | const innerHash = delegatedRecoverySDK.sha256(countersignedToken.data); 315 | const expectedUsername = req.body.state; 316 | const record = tokenRecords.find((record) => record.hash === innerHash); 317 | 318 | if (record === undefined) { 319 | errorFunction('No record of this token. Perhaps you restarted this app since it was issued?'); 320 | } else if (record.status !== recordStatus.confirmed) { 321 | errorFunction('The recovery token from this app wasn\'t marked as valid.'); 322 | } else if (expectedUsername !== undefined && expectedUsername !== '' && expectedUsername !== record.username) { 323 | errorFunction('The recovery token from this app was not for ' + expectedUsername); 324 | } 325 | 326 | if (!errorFlag) { 327 | res.render(path.template.recoverAccountSuccess, { 328 | "username": record.username, 329 | }); 330 | } 331 | } 332 | }, (e) => { 333 | errorFunction(e); 334 | }); 335 | }); 336 | 337 | app.post(delegatedRecoverySDK.STATUS_PATH, (req, res) => { 338 | const id = req.body.id; 339 | const tokenRecord = tokenRecords.find((record) => record.id === id); 340 | if (tokenRecord !== undefined) { 341 | switch (req.body.status) { 342 | case 'save-success': 343 | tokenRecord.status = recordStatus.confirmed; 344 | break; 345 | case 'save-failure': 346 | tokenRecords.splice(tokenRecords.findIndex((record) => record.id === id), 1); 347 | break; 348 | case 'token-repudiated': 349 | tokenRecord.status = recordStatus.invalid; 350 | break; 351 | } 352 | } 353 | res.status(200).send(); 354 | }); 355 | 356 | app.listen(app.get('port'), () => { 357 | console.log('Node app is running on port', app.get('port')); 358 | }); -------------------------------------------------------------------------------- /examples/nodejs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "delegated-recovery-example", 3 | "version": "1.0.0", 4 | "description": "Heroku-hostable Node.js Delegated Account Recovery Example for Express", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/facebook/DelegatedRecoveryReferenceImplementation.git" 9 | }, 10 | "author": "hillbrad@fb.com", 11 | "bugs": { 12 | "url": "https://github.com/facebook/DelegatedRecoveryReferenceImplementation/issues" 13 | }, 14 | "dependencies": { 15 | "body-parser": ">= 1.15.0", 16 | "express": ">= 4.13.3", 17 | "express-mustache": ">= 1.0.3", 18 | "delegated-account-recovery": ">= 1.0.2" 19 | }, 20 | "engines": { 21 | "node": ">= 6.10.0" 22 | }, 23 | "homepage": "https://github.com/facebook/DelegatedRecoveryReferenceImplementation#readme" 24 | } 25 | -------------------------------------------------------------------------------- /examples/nodejs/path.js: -------------------------------------------------------------------------------- 1 | // Copyright 2016-present, Facebook, Inc. 2 | // All rights reserved. 3 | // 4 | // This source code is licensed under the license found in the 5 | // LICENSE-examples file in the root directory of this source tree. 6 | 'use strict;'; 7 | 8 | const web = { 9 | default: '/', 10 | saveToken: '/home/', 11 | saveTokenReturn: '/save-token-return/', 12 | recoverAccountReturn: '/recover-account-return/', 13 | icon: '/icon.png', 14 | privacyPolicy: '/privacy.html', 15 | recoverIdentifyAccount: '/identify-account/', 16 | invalidateToken: '/invalidate/', 17 | renewToken: '/renew/', 18 | }; 19 | 20 | const template = { 21 | default: 'index.mustache', 22 | saveToken: 'save.mustache', 23 | invalidateToken: 'invalidate.mustache', 24 | saveTokenSuccess: 'save_token_success.mustache', 25 | saveTokenFailure: 'save_token_failure.mustache', 26 | recoverAccountSuccess: 'recover_account_success.mustache', 27 | recoverAccountFailure: 'recover_account_failure.mustache', 28 | identifyAccount: 'identify_account.mustache', 29 | recoverAccount: 'recover_account.mustache', 30 | noSavedToken: 'no_token.mustache', 31 | unknownToken: 'save_token_unknown.mustache', 32 | renewToken: 'renew.mustache', 33 | }; 34 | 35 | module.exports = { 36 | web: web, 37 | template: template, 38 | }; 39 | -------------------------------------------------------------------------------- /examples/nodejs/static/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/facebookarchive/DelegatedRecoveryReferenceImplementation/9cf7c1cabddac828c854aa3f8d697e37cd2e33b0/examples/nodejs/static/icon.png -------------------------------------------------------------------------------- /examples/nodejs/static/privacy.html: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | Sample Privacy Policy 11 | 12 | 13 | 14 | Example Application Privacy Policy 15 | ================================== 16 | This examples application is for non-commercial testing and evaluation 17 | purposes only. It does not retain permanent records but should not be used 18 | with any sensitive data. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 23 | FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 24 | ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 25 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 26 | 27 | 28 | -------------------------------------------------------------------------------- /examples/nodejs/static/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2016-present, Facebook, Inc. 3 | All rights reserved. 4 | 5 | This source code is licensed under the license found in the 6 | LICENSE-examples file in the root directory of this source tree. 7 | */ 8 | body { 9 | background-color: white; 10 | border: 3px solid black; 11 | border-radius: 1.5em; 12 | box-shadow: 10px 10px 5px 0px rgba(0,0,0,0.5); 13 | font-family: 'Roboto', Helvetica, sans-serif; 14 | font-size: 16px; 15 | font-weight: 100; 16 | height: 100%; 17 | margin-top: 1.5em; 18 | margin-left: 1.5em; 19 | max-height: 550px; 20 | padding: 2em; 21 | text-align: center; 22 | width: 300px; 23 | } 24 | 25 | 26 | #title { 27 | font-size: 20px; 28 | font-weight: 200; 29 | } 30 | 31 | #emoji { 32 | font-size: 100px; 33 | } 34 | 35 | #message { 36 | padding-bottom: .3em; 37 | padding-top: .3em; 38 | } 39 | 40 | input { 41 | margin-left: auto; 42 | margin-right: auto; 43 | margin-bottom: .5em; 44 | padding: .5em; 45 | width: 160px; 46 | } 47 | 48 | .button { 49 | border: 2px solid lightblue; 50 | background-color: blue; 51 | color: white; 52 | display: block; 53 | font-family: 'Roboto', Helvetica, sans-serif; 54 | font-size: 16px; 55 | font-weight: 100; 56 | text-decoration: none; 57 | text-shadow: 1px 1px gray; 58 | } 59 | 60 | .button:hover, .button:active { 61 | box-shadow: 1px 1px 1px lightblue, -1px -1px 1px lightblue; 62 | } 63 | -------------------------------------------------------------------------------- /examples/nodejs/static/templates/identify_account.mustache: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | Identify Account 13 | 14 | 15 |
Identify Account
16 |
🎭
17 |
18 | Enter the username of the account you wish to recover: 19 |
20 |
21 | 22 | 23 |
24 |
If you don't remember your username, you can try to use your Facebook account. 25 | 26 | -------------------------------------------------------------------------------- /examples/nodejs/static/templates/index.mustache: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | Delegated Recovery Sample App 13 | 14 | 15 |
Delegated Recovery Sample App
16 |
🔐
17 |
18 | Enter a username to login. 19 |
20 |
21 | 22 | 23 |
24 | Forgot Password 25 | 26 | -------------------------------------------------------------------------------- /examples/nodejs/static/templates/invalidate.mustache: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | Welcome, {{username}} 13 | 14 | 15 |
Welcome, {{username}}
16 |
🔖
17 |
18 | You can recover with Facebook. 19 |
20 |
21 | 22 | 23 | 24 |
25 |
26 | 27 | 28 | 29 |
30 | 31 | 32 | -------------------------------------------------------------------------------- /examples/nodejs/static/templates/no_token.mustache: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | Recover {{username}}'s Account 13 | 14 | 15 |
Recover {{username}}'s Account
16 |
😞
17 |
18 | This username hasn't been set up for recovery, or you restarted the app or 19 | deleted your tokens. 20 |
21 | 22 | -------------------------------------------------------------------------------- /examples/nodejs/static/templates/recover_account.mustache: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | Recover {{username}}'s Account 13 | 14 | 15 |
Recover {{username}}'s Account
16 |
🔑
17 |
18 | You can recover with Facebook. 19 |
20 |
21 | 22 | 23 | 24 |
25 | 26 | -------------------------------------------------------------------------------- /examples/nodejs/static/templates/recover_account_failure.mustache: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | Something went wrong. 13 | 14 | 15 | 16 |
Something went wrong. We could not recover your account.
17 |
💣
18 |
19 | {{exception}} 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /examples/nodejs/static/templates/recover_account_success.mustache: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | Welcome back, {{username}}! 13 | 14 | 15 | 16 |
Welcome back, {{username}}!
17 |
🌟
18 |
19 | You successfully recovered access to your account. 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /examples/nodejs/static/templates/renew.mustache: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | Welcome, {{username}} 13 | 14 | 15 |
Welcome, {{username}}
16 |
🔖
17 |
18 | Click the button below to confirm renewing your token: 19 |
20 |
21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | -------------------------------------------------------------------------------- /examples/nodejs/static/templates/save.mustache: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | Welcome, {{username}} 13 | 14 | 15 | 16 |
Welcome, {{username}}
17 |
😨
18 |
19 | You don't have a way to recover if you forget your password! 20 |
21 |
22 | 23 | 24 | 25 | 26 |
27 | 28 | -------------------------------------------------------------------------------- /examples/nodejs/static/templates/save_token_failure.mustache: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | That didn't work. 13 | 14 | 15 | 16 |
That didn't work.
17 |
😕
18 |
19 | It looks like you weren't successful in setting up a recovery method. 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /examples/nodejs/static/templates/save_token_success.mustache: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | Thanks, {{username}} 13 | 14 | 15 | 16 |
Thanks, {{username}}
17 |
🔗
18 |
19 | You can use Facebook to restore access if you ever forget your password. 20 |
21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /examples/nodejs/static/templates/save_token_unknown.mustache: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | Unknown Token 13 | 14 | 15 |
Unknown Token
16 |
😵
17 |
18 | Something went wrong. We don't have a record of the token you just saved. 19 | Maybe this app was restarted while you were at Facebook? 20 |
21 | Start Over 22 | 23 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | com.facebook.delegatedrecovery 6 | delegatedrecovery-root 7 | pom 8 | 0.1 9 | ${project.groupId}:${project.artifactId} 10 | 11 | 12 | Facebook, Inc. 13 | 14 | 15 | 16 | sdk/java-src 17 | examples/java 18 | 19 | -------------------------------------------------------------------------------- /sdk/java-src/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | 4.0.0 13 | 14 | 15 | com.facebook 16 | facebook-oss-pom 17 | 13 18 | 19 | 20 | com.facebook.delegatedrecovery 21 | delegatedrecovery-sdk 22 | 1.0.2-SNAPSHOT 23 | ${project.groupId}:${project.artifactId} 24 | jar 25 | SDK used for implementing Delegated Account Recovery in Java Web Apps 26 | 2016 27 | 28 | 29 | 30 | 3-Clause BSD License 31 | https://github.com/facebook/DelegatedRecoveryReferenceImplementation 32 | 33 | 34 | 35 | 36 | 37 | Brad Hill 38 | hillbrad@fb.com 39 | Facebook 40 | 41 | 42 | 43 | 44 | scm:git:git://github.com/facebook/DelegatedRecoveryReferenceImplementation.git 45 | scm:git:git://github.com/facebook/DelegatedRecoveryReferenceImplementation.git 46 | https://github.com/facebook/DelegatedRecoveryReferenceImplementation 47 | HEAD 48 | 49 | 50 | 51 | 1.8 52 | 3.0.1 53 | 3.1 54 | 5.1.3 55 | false 56 | 57 | 58 | 59 | 60 | org.bouncycastle 61 | bcpkix-jdk15on 62 | 1.56 63 | 64 | 65 | org.bouncycastle 66 | bcprov-jdk15on 67 | 1.56 68 | 69 | 70 | javax.json 71 | javax.json-api 72 | 1.0 73 | 74 | 75 | 76 | 77 | 78 | ossrh 79 | https://oss.sonatype.org/content/repositories/snapshots 80 | 81 | 82 | ossrh 83 | https://oss.sonatype.org/service/local/staging/deploy/maven2/ 84 | 85 | 86 | 87 | 88 | 89 | 90 | org.apache.maven.plugins 91 | maven-release-plugin 92 | 2.5.3 93 | 94 | oss-release 95 | true 96 | forked-path 97 | ${fb.release.push-changes} 98 | true 99 | clean install 100 | false 101 | deploy 102 | 103 | 104 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /sdk/java-src/src/license/LICENSE-HEADER.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) ${inceptionYear}-present, Facebook, Inc. 2 | All rights reserved. 3 | 4 | This source code is licensed under the BSD-style license found in the 5 | LICENSE file in the root directory of this source tree. An additional grant 6 | of patent rights can be found in the PATENTS file in the same directory. -------------------------------------------------------------------------------- /sdk/java-src/src/main/java/com/facebook/delegatedrecovery/AccountProviderConfiguration.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2016-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the BSD-style license found in the 6 | * LICENSE file in the root directory of this source tree. An additional grant 7 | * of patent rights can be found in the PATENTS file in the same directory. 8 | */ 9 | package com.facebook.delegatedrecovery; 10 | 11 | import javax.json.Json; 12 | import javax.json.JsonArrayBuilder; 13 | import javax.json.JsonBuilderFactory; 14 | import javax.json.JsonObject; 15 | import java.net.MalformedURLException; 16 | import java.net.URL; 17 | import java.security.interfaces.ECPublicKey; 18 | import java.util.Base64; 19 | 20 | /** 21 | * Represents the configuration published by an AccountProvider. 22 | */ 23 | public class AccountProviderConfiguration extends DelegatedRecoveryConfiguration { 24 | 25 | private final URL saveTokenReturn; 26 | private final URL recoverAccountReturn; 27 | private final ECPublicKey[] pubKeys; 28 | 29 | /** 30 | * Constructor for publication. 31 | * 32 | * @param issuer The RFC-6454 origin of the recovery service 33 | * @param saveTokenReturn The URL to call to save the token 34 | * @param recoverAccountReturn The URL to call to recover the account 35 | * @param privacyPolicy A URL to the privacy policy 36 | * @param tokensignPubkeysSecp256r1 The token signing keys 37 | * @param icon152px a URL to a icon 38 | * @throws MalformedURLException if the URL fo the privacy policy, icon, saveTokenReturn, or recoverAccountReturn is malformed 39 | */ 40 | public AccountProviderConfiguration( 41 | final String issuer, 42 | final String saveTokenReturn, 43 | final String recoverAccountReturn, 44 | final String privacyPolicy, 45 | final String[] tokensignPubkeysSecp256r1, 46 | final String icon152px) throws MalformedURLException { 47 | super(issuer, privacyPolicy, icon152px); 48 | this.saveTokenReturn = new URL(saveTokenReturn); 49 | this.recoverAccountReturn = new URL(recoverAccountReturn); 50 | 51 | JsonBuilderFactory factory = Json.createBuilderFactory(null); 52 | JsonArrayBuilder keyArray = factory.createArrayBuilder(); 53 | for (String key : tokensignPubkeysSecp256r1) { 54 | keyArray.add(key); 55 | } 56 | this.pubKeys = keysFromJsonArray(keyArray.build()); 57 | } 58 | 59 | /** 60 | * Constructor from JSON, as when retrieved remotely from a 3rd party 61 | * 62 | * @param json JSON blob to parse the data from 63 | * @throws MalformedURLException If any of the URL's in the json are malformed 64 | * @throws InvalidOriginException If the issue is invalid. 65 | */ 66 | public AccountProviderConfiguration(final JsonObject json) throws MalformedURLException, InvalidOriginException { 67 | super(json); 68 | String strString = json.getString("save-token-return"); 69 | this.saveTokenReturn = new URL(strString); 70 | String rarString = json.getString("recover-account-return"); 71 | this.recoverAccountReturn = new URL(rarString); 72 | pubKeys = keysFromJsonArray(json.getJsonArray("tokensign-pubkeys-secp256r1")); 73 | } 74 | 75 | /** 76 | * @return instantiated public keys for ECDSA on secp256r1 curve 77 | */ 78 | public ECPublicKey[] getPubKeys() { 79 | return pubKeys == null ? null : pubKeys.clone(); 80 | } 81 | 82 | /** 83 | * @return URL for save-token-return 84 | */ 85 | public URL getSaveTokenReturn() { 86 | return saveTokenReturn; 87 | } 88 | 89 | /** 90 | * @return URL for recover-account-return 91 | */ 92 | public URL getRecoverAccountReturn() { 93 | return recoverAccountReturn; 94 | } 95 | 96 | public String toString() { 97 | final JsonBuilderFactory factory = Json.createBuilderFactory(null); 98 | final JsonArrayBuilder keyArray = factory.createArrayBuilder(); 99 | 100 | for (final ECPublicKey key : pubKeys) { 101 | keyArray.add(Base64.getEncoder().encodeToString(key.getEncoded())); 102 | } 103 | 104 | final JsonObject config = factory.createObjectBuilder().add("issuer", getIssuer()) 105 | .add("save-token-return", getSaveTokenReturn().toString()) 106 | .add("recover-account-return", getRecoverAccountReturn().toString()) 107 | .add("icon-152px", getIcon152px().toString()).add("privacy-policy", getPrivacyPolicy().toString()) 108 | .add("tokensign-pubkeys-secp256r1", keyArray.build()).build(); 109 | 110 | return config.toString(); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /sdk/java-src/src/main/java/com/facebook/delegatedrecovery/CountersignedRecoveryToken.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2016-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the BSD-style license found in the 6 | * LICENSE file in the root directory of this source tree. An additional grant 7 | * of patent rights can be found in the PATENTS file in the same directory. 8 | */ 9 | package com.facebook.delegatedrecovery; 10 | 11 | import org.bouncycastle.crypto.digests.SHA256Digest; 12 | 13 | import java.io.UnsupportedEncodingException; 14 | import java.security.InvalidKeyException; 15 | import java.security.SignatureException; 16 | import java.security.interfaces.ECPublicKey; 17 | import java.text.ParseException; 18 | import java.util.Arrays; 19 | import java.util.Base64; 20 | import java.util.Date; 21 | 22 | /** 23 | * Represents a countersigned recovery token. 24 | */ 25 | public class CountersignedRecoveryToken extends RecoveryToken { 26 | 27 | /** 28 | * Extract the issuer from an encoded token so that the configuration can be 29 | * fetched. 30 | * 31 | * @param encoded The encoded token 32 | * @return RFC6454 origin string 33 | */ 34 | public static String extractIssuer(final String encoded) { 35 | try { 36 | final byte[] tmp = Base64.getDecoder().decode(encoded); 37 | int offset = 19; 38 | final int issuerLength = tmp[offset] << 8 & 0xFF00 | tmp[offset + 1] & 0xFF; 39 | offset += 2; 40 | return new String(Arrays.copyOfRange(tmp, offset, offset + issuerLength), "US-ASCII"); 41 | } catch (final UnsupportedEncodingException e) { 42 | e.printStackTrace(); 43 | System.err.println("US-ASCII encoding was unsupported. Cannot continue."); 44 | System.exit(1); 45 | return null; // unreachable 46 | } 47 | } 48 | 49 | /** 50 | * Construct a CountersignedRecovery token from an encoded string. This will 51 | * automatically verify the signature, issuer, audience and allowed age of the 52 | * token. InvalidTokenException is thrown if any of these checks fail. The 53 | * caller is responsible for checking against a replay cache, if desired. 54 | * 55 | * @param encoded Base64 encoded string of the binary countersigned token 56 | * @param issuer The RFC-6454 origin of the recovery service 57 | * @param audience RFC-6454 origin of the your service 58 | * @param keys The countersigning keys to verify the token 59 | * @param allowedClockSkewSec How much clock skew to allow in seconds 60 | * @param binding token binding string to verify against, usually null 61 | * @throws InvalidTokenException If any of the checks fail. 62 | * @throws InvalidOriginException If the issuer is invalid 63 | * @throws SignatureException If the keys are invalid. 64 | * @throws InvalidKeyException If the keys are invalid. 65 | */ 66 | public CountersignedRecoveryToken( 67 | final String encoded, 68 | final String issuer, 69 | final String audience, 70 | final ECPublicKey[] keys, 71 | final int allowedClockSkewSec, 72 | final byte[] binding) throws InvalidTokenException, InvalidOriginException, InvalidKeyException, SignatureException { 73 | super(encoded); 74 | if (!this.issuer.equals(issuer)) { 75 | throw new InvalidTokenException("issuer doesn't match expected"); 76 | } 77 | if (!this.audience.equals(audience)) { 78 | throw new InvalidTokenException("audience doesn't match expected"); 79 | } 80 | if(binding != null && !Arrays.equals(this.binding, binding)) { 81 | throw new InvalidTokenException("binding doesn't match expected"); 82 | } 83 | 84 | if(!this.isSignatureValid(keys)) { 85 | throw new InvalidTokenException("token signature didn't verify"); 86 | } 87 | 88 | try { 89 | final long issuedTime = DelegatedRecoveryUtils.fromISO8601(this.issuedTime).getTime(); 90 | final long now = new Date().getTime(); 91 | final long skew = Math.abs(issuedTime - now); 92 | 93 | if (skew > (allowedClockSkewSec * 1000 /* seconds */)) { 94 | throw new InvalidTokenException("Issued time for token outside valid clock skew window."); 95 | } 96 | } catch (ParseException pe) { 97 | throw new InvalidTokenException("unparsable issuedTime", pe); 98 | } 99 | 100 | } 101 | 102 | /** 103 | * Utility method to quickly get a hex-encoded SHA256 digest of the data field 104 | * of the countersigned token, which contains the original token. 105 | * 106 | * @return hex encoded string of SHA256 digest 107 | */ 108 | public String getInnerTokenHash() { 109 | final SHA256Digest digest = new SHA256Digest(); 110 | final byte[] hash = new byte[digest.getByteLength()]; 111 | digest.update(data, 0, data.length); 112 | digest.doFinal(hash, 0); 113 | return DelegatedRecoveryUtils.encodeHex(hash); 114 | } 115 | 116 | protected void typedSanityCheck() { 117 | if (type != RecoveryToken.TYPE_COUNTERSIGNED_TOKEN) { 118 | throw new IllegalArgumentException("illegal token type"); 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /sdk/java-src/src/main/java/com/facebook/delegatedrecovery/DelegatedRecoveryConfiguration.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2016-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the BSD-style license found in the 6 | * LICENSE file in the root directory of this source tree. An additional grant 7 | * of patent rights can be found in the PATENTS file in the same directory. 8 | */ 9 | package com.facebook.delegatedrecovery; 10 | 11 | import org.bouncycastle.jce.ECNamedCurveTable; 12 | import org.bouncycastle.jce.ECPointUtil; 13 | import org.bouncycastle.jce.provider.BouncyCastleProvider; 14 | import org.bouncycastle.jce.spec.ECNamedCurveParameterSpec; 15 | import org.bouncycastle.jce.spec.ECNamedCurveSpec; 16 | 17 | import javax.json.JsonArray; 18 | import javax.json.JsonObject; 19 | import java.net.MalformedURLException; 20 | import java.net.URL; 21 | import java.security.KeyFactory; 22 | import java.security.NoSuchAlgorithmException; 23 | import java.security.interfaces.ECPublicKey; 24 | import java.security.spec.ECPoint; 25 | import java.security.spec.ECPublicKeySpec; 26 | import java.security.spec.InvalidKeySpecException; 27 | import java.util.ArrayList; 28 | import java.util.Base64; 29 | import java.util.Date; 30 | 31 | /** 32 | * Abstract superclass for RecoveryProvider and AccountProvider configurations. 33 | */ 34 | public abstract class DelegatedRecoveryConfiguration { 35 | 36 | /** 37 | * Enum determining whether an instantiated configuration is for a recovery or 38 | * account provider. A given JSON configuration published at the well-known 39 | * endpoint may contain the keys representing information for use in both 40 | * roles, but must be instantiated separately, in a typed fashion, for use in 41 | * code 42 | */ 43 | public enum ConfigType { 44 | ACCOUNT_PROVIDER, RECOVERY_PROVIDER 45 | } 46 | 47 | /** 48 | * The well=known URL path at which a delegated recovery configuration is 49 | * published 50 | */ 51 | public static final String CONFIG_PATH = "/.well-known/delegated-account-recovery/configuration"; 52 | 53 | /** 54 | * The well=known URL path at which a the token status endpoint must listen 55 | */ 56 | public static final String TOKEN_STATUS_PATH = "/.well-known/delegated-account-recovery/token-status"; 57 | 58 | /** 59 | * Time in seconds until a fetched configuration is considered stale, by 60 | * default. Override by calling setMaxAge() with the value of the 61 | * Cache-Control header or your own preferred default. 62 | */ 63 | public static final int DEFAULT_EXPIRY = 60 * 60; 64 | 65 | // public keys are of fixed length when encoded, so this ASN.1 prefix is 66 | // always the same. sometimes it is easier 67 | // to just add/remove it directly to move between encoded and unencoded public 68 | // points 69 | private final static byte[] PEM_ASN1_PREFIX = new byte[] { 48, 89, 48, 19, 6, 7, 42, -122, 72, -50, 61, 2, 1, 6, 8, 70 | 42, -122, 72, -50, 61, 3, 1, 7, 3, 66, 0 }; 71 | 72 | private final String issuer; 73 | private final URL privacyPolicy; 74 | private URL icon152px; 75 | private Date expires = new Date(new Date().getTime() + (DEFAULT_EXPIRY * 1000)); 76 | 77 | /** 78 | * @return RFC 6454 Origin string representing issuer 79 | */ 80 | public String getIssuer() { 81 | return issuer; 82 | } 83 | 84 | /** 85 | * @return Privacy policy URL 86 | */ 87 | public URL getPrivacyPolicy() { 88 | return privacyPolicy; 89 | } 90 | 91 | /** 92 | * @return URL of 152x152 pixel PING icon file 93 | */ 94 | public URL getIcon152px() { 95 | return icon152px; 96 | } 97 | 98 | /** 99 | * If a configuration is served with a Cache-Control HTTP header, the max-age 100 | * value can be set at construction time to determine expiration. 101 | * 102 | * @param maxAge new max age value 103 | */ 104 | public void setMaxAge(final int maxAge) { 105 | this.expires = new Date(new Date().getTime() + (maxAge * 1000)); 106 | } 107 | 108 | /** 109 | * Test if the configuration is expired and should be re-fetched based on its 110 | * max age 111 | * 112 | * @return if configuration is expired 113 | */ 114 | public boolean isExpired() { 115 | return new Date().after(expires); 116 | } 117 | 118 | /** 119 | * Superclass shared constructor logic 120 | * 121 | * @param issuer The issuer 122 | * @param privacyPolicy the privacy policy URL 123 | * @param icon152px a URL to an icon 124 | * @throws MalformedURLException if the url for the privacy policy or icon is malformed 125 | */ 126 | protected DelegatedRecoveryConfiguration(final String issuer, final String privacyPolicy, final String icon152px) 127 | throws MalformedURLException { 128 | this.issuer = issuer; 129 | this.privacyPolicy = new URL(privacyPolicy); 130 | this.icon152px = new URL(icon152px); 131 | } 132 | 133 | /** 134 | * Superclass shared constructor logic 135 | * 136 | * @param json The json to parse the data out of 137 | * @throws MalformedURLException if the privacy policy url is malformed 138 | * @throws InvalidOriginException if the issuer is invalid 139 | */ 140 | protected DelegatedRecoveryConfiguration(final JsonObject json) throws MalformedURLException, InvalidOriginException { 141 | final String issuer = json.getString("issuer"); 142 | DelegatedRecoveryUtils.validateOrigin(issuer); 143 | this.issuer = issuer; 144 | final String privacyPolicy = json.getString("privacy-policy"); 145 | this.privacyPolicy = new URL(privacyPolicy); 146 | try { 147 | String icon152px = json.getString("icon-152px"); 148 | this.icon152px = new URL(icon152px); 149 | } catch (Exception e) { 150 | this.icon152px = null; 151 | } 152 | } 153 | 154 | 155 | 156 | /** 157 | * Turn the JSON public key array from a configuration into a set of usable 158 | * public keys for ECDSA on secp256r1 159 | * 160 | * @param array The JSON public key array 161 | * @return array of public keys decoded from the JSON array of base64 encoded 162 | * strings 163 | */ 164 | protected static ECPublicKey[] keysFromJsonArray(final JsonArray array) { 165 | try { 166 | final ECNamedCurveParameterSpec spec = ECNamedCurveTable.getParameterSpec("prime256v1"); 167 | final KeyFactory kf = KeyFactory.getInstance("EC", new BouncyCastleProvider()); 168 | final ECNamedCurveSpec params = new ECNamedCurveSpec("prime256v1", spec.getCurve(), spec.getG(), spec.getN()); 169 | final ArrayList pubKeys = new ArrayList(array.size()); 170 | 171 | for (int i = 0; i < array.size(); i++) { 172 | final String b64 = array.getString(i); 173 | final byte[] pubKeyAsn1 = Base64.getDecoder().decode(b64); 174 | final byte[] pubKey = new byte[pubKeyAsn1.length - PEM_ASN1_PREFIX.length]; // trim 175 | // PEM 176 | // ASN.1 177 | // prefix 178 | System.arraycopy(pubKeyAsn1, PEM_ASN1_PREFIX.length, pubKey, 0, pubKey.length); 179 | final ECPoint point = ECPointUtil.decodePoint(params.getCurve(), pubKey); 180 | final ECPublicKeySpec pubKeySpec = new ECPublicKeySpec(point, params); 181 | try { 182 | final ECPublicKey pk = (ECPublicKey) kf.generatePublic(pubKeySpec); 183 | pubKeys.add(pk); 184 | } catch (InvalidKeySpecException e) { 185 | System.err.println("InvalidKeySpecException while processing " + b64); 186 | } 187 | } 188 | return pubKeys.toArray(new ECPublicKey[pubKeys.size()]); 189 | } catch (NoSuchAlgorithmException e) { 190 | e.printStackTrace(); 191 | System.err.println("Unable to initialize ECDSA key factor for prime256v1. Cannot continue."); 192 | System.exit(1); 193 | return null; // unreachable but Eclipse complier wants me to return 194 | // something. :P 195 | } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /sdk/java-src/src/main/java/com/facebook/delegatedrecovery/DelegatedRecoveryUtils.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2016-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the BSD-style license found in the 6 | * LICENSE file in the root directory of this source tree. An additional grant 7 | * of patent rights can be found in the PATENTS file in the same directory. 8 | */ 9 | package com.facebook.delegatedrecovery; 10 | 11 | import org.bouncycastle.asn1.sec.SECNamedCurves; 12 | import org.bouncycastle.asn1.x9.X9ECParameters; 13 | import org.bouncycastle.crypto.digests.SHA256Digest; 14 | import org.bouncycastle.crypto.params.ECDomainParameters; 15 | import org.bouncycastle.openssl.PEMKeyPair; 16 | import org.bouncycastle.openssl.PEMParser; 17 | import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; 18 | 19 | import javax.json.Json; 20 | import javax.json.JsonObject; 21 | import javax.json.JsonReader; 22 | import java.io.*; 23 | import java.net.URL; 24 | import java.nio.charset.StandardCharsets; 25 | import java.security.KeyPair; 26 | import java.security.SecureRandom; 27 | import java.security.interfaces.ECPublicKey; 28 | import java.text.DateFormat; 29 | import java.text.ParseException; 30 | import java.text.SimpleDateFormat; 31 | import java.util.Base64; 32 | import java.util.Date; 33 | import java.util.TimeZone; 34 | import java.util.regex.Pattern; 35 | 36 | /** 37 | * Various utility functions for working with the Delegated Account Recovery 38 | * protocol 39 | */ 40 | public class DelegatedRecoveryUtils { 41 | 42 | private static final X9ECParameters curve = SECNamedCurves.getByName("secp256r1"); 43 | private static final char[] hexDigits = 44 | { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' }; 45 | 46 | private static final Pattern ORIGIN_REGEX = Pattern 47 | .compile("^https://(?:[a-z0-9-]{1,63}\\.)+(?:[a-z]{2,63})(:[\\d]+)?$"); 48 | 49 | private static final SecureRandom secureRandom = new SecureRandom(); 50 | 51 | protected static final ECDomainParameters P256_DOMAIN_PARAMS = new ECDomainParameters(curve.getCurve(), curve.getG(), 52 | curve.getN(), curve.getH()); 53 | 54 | private static final String BEGIN_PUBLIC_KEY = "-----BEGIN PUBLIC KEY-----"; 55 | private static final String END_PUBLIC_KEY = "-----END PUBLIC KEY-----"; 56 | private static final String BEGIN_EC_PRIVATE_KEY = "-----BEGIN EC PRIVATE KEY-----"; 57 | private static final String END_EC_PRIVATE_KEY = "-----END EC PRIVATE KEY-----"; 58 | 59 | /** 60 | * Converts a base64 encoded string of an ASN.1 encoded public key into the 61 | * PEM format with appropriate headers and line breaks so it can be read by 62 | * OpenSSL or a compatible parser 63 | * 64 | * @param key A base64 encoded ASN.1 encoded public key 65 | * @return PEM string 66 | */ 67 | public static String publicKeyToPEM(final ECPublicKey key) { 68 | final StringBuilder out = new StringBuilder(300); 69 | out.append(BEGIN_PUBLIC_KEY).append("\n") 70 | .append(Base64.getMimeEncoder(64, System.getProperty("line.separator").getBytes(StandardCharsets.UTF_8)).encodeToString(key.getEncoded())) 71 | .append("\n") 72 | .append(END_PUBLIC_KEY).append("\n") 73 | .append("\n"); 74 | return out.toString(); 75 | } 76 | 77 | /** 78 | * Loads a private key on the P-256 curve from a PEM file of the type created 79 | * by openssl ecparam -name prime256v1 -genkey -noout -out filename 80 | * 81 | * @param filename The filename of the pem file 82 | * @return an EC key pair 83 | * @throws Exception If the file fails to read or parse. 84 | */ 85 | public static KeyPair keyPairFromPEMFile(final String filename) throws Exception { 86 | final Reader reader = new InputStreamReader(new FileInputStream(filename), StandardCharsets.UTF_8); 87 | final PEMParser pemParser = new PEMParser(reader); 88 | final KeyPair kp = new JcaPEMKeyConverter().getKeyPair((PEMKeyPair) pemParser.readObject()); 89 | pemParser.close(); 90 | return kp; 91 | } 92 | 93 | /** 94 | * As keyPairFromPEMFile but with a string instead of a file 95 | * 96 | * @param key The key from a PEM file as a string 97 | * @return an EC key pair 98 | * @throws Exception If the string failes to parse. 99 | */ 100 | public static KeyPair keyPairFromPEMString(final String key) throws Exception { 101 | final StringBuilder pem = new StringBuilder(300); 102 | pem.append(BEGIN_EC_PRIVATE_KEY + "\n"); 103 | for (int i = 0; i < key.length(); i++) { 104 | pem.append(key.charAt(i)); 105 | if ((i + 1) % 64 == 0) { 106 | pem.append("\n"); 107 | } 108 | } 109 | pem.append("\n" + END_EC_PRIVATE_KEY + "\n"); 110 | 111 | final StringReader reader = new StringReader(pem.toString()); 112 | final PEMParser pemParser = new PEMParser(reader); 113 | final KeyPair kp = new JcaPEMKeyConverter().getKeyPair((PEMKeyPair) pemParser.readObject()); 114 | pemParser.close(); 115 | return kp; 116 | } 117 | 118 | /** 119 | * Simple utility method to return a hex-encoded string of the SHA256 digest 120 | * of a byte[] 121 | * 122 | * @param bytes The bytes to encode 123 | * @return The hex encoded bytes in a String 124 | */ 125 | public static String sha256(final byte[] bytes) { 126 | // hash of the token to re-identify it later 127 | final SHA256Digest digest = new SHA256Digest(); 128 | final byte[] hash = new byte[digest.getByteLength()]; 129 | digest.update(bytes, 0, bytes.length); 130 | digest.doFinal(hash, 0); 131 | return DelegatedRecoveryUtils.encodeHex(hash); 132 | } 133 | 134 | /** 135 | * Makes an HTTPS request to fetch and parse the JSON delegated account 136 | * recovery protocol configuration from the well-known location. 137 | * 138 | * @param origin The origin to fetch from, this will have the delegated recovery config path appended to it 139 | * @param type The type of config 140 | * @return configuration. Cast to AccountProviderConfiguation or 141 | * RecoveryProviderConfiguration based on the type parameter 142 | * @throws Exception If something fails while fetching the config. 143 | */ 144 | public static DelegatedRecoveryConfiguration fetchConfiguration( 145 | final String origin, 146 | final DelegatedRecoveryConfiguration.ConfigType type) throws Exception { 147 | DelegatedRecoveryUtils.validateOrigin(origin); 148 | 149 | final URL url = new URL(origin + DelegatedRecoveryConfiguration.CONFIG_PATH); 150 | try (final InputStream is = url.openStream(); final JsonReader rdr = Json.createReader(is)) { 151 | final JsonObject obj = rdr.readObject(); 152 | DelegatedRecoveryConfiguration config; 153 | if (type == DelegatedRecoveryConfiguration.ConfigType.ACCOUNT_PROVIDER) { 154 | config = new AccountProviderConfiguration(obj); 155 | } else { 156 | config = new RecoveryProviderConfiguration(obj); 157 | } 158 | // TODO set max-age from Cache-Control header 159 | return config; 160 | } 161 | } 162 | 163 | /** 164 | * convenience method to encode a byte[] as a hex string 165 | * 166 | * @param rawBytes The bytes to encode 167 | * @return a Hex string representing rawBytes 168 | */ 169 | public static String encodeHex(final byte[] rawBytes) { 170 | final char[] hexChars = new char[rawBytes.length * 2]; 171 | for (int i = 0; i < rawBytes.length; i++) { 172 | hexChars[i * 2] = DelegatedRecoveryUtils.hexDigits[(0xF0 & rawBytes[i]) >>> 4]; 173 | hexChars[i * 2 + 1] = DelegatedRecoveryUtils.hexDigits[0x0F & rawBytes[i]]; 174 | } 175 | return new String(hexChars); 176 | } 177 | 178 | /** 179 | * Validate that a string conforms to an RFC6454 ASCII Origin with the https 180 | * scheme. 181 | * @param origin The issuer or audience origin 182 | * @throws InvalidOriginException if the origin is invalid 183 | */ 184 | public static void validateOrigin(final String origin) throws InvalidOriginException { 185 | if (!(DelegatedRecoveryUtils.ORIGIN_REGEX.matcher(origin).matches())) { 186 | throw new InvalidOriginException( 187 | origin + " is not a valid RFC 6454 ASCII Origin with https:// scheme and no path component."); 188 | } 189 | } 190 | 191 | /** 192 | * Get the current time formatted to ISO8601 193 | * 194 | * @return date string 195 | */ 196 | public static String nowISO8601() { 197 | final TimeZone tz = TimeZone.getTimeZone("UTC"); 198 | final DateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX"); 199 | df.setTimeZone(tz); 200 | return df.format(new Date()); 201 | } 202 | 203 | /** 204 | * Get a Date from an ISO8601 string 205 | * 206 | * @param isoDateString The ISO8601 string 207 | * @return Date object 208 | * @throws ParseException if unable to parse date from string 209 | */ 210 | public static Date fromISO8601(final String isoDateString) throws ParseException { 211 | final DateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX"); 212 | return df.parse(isoDateString); 213 | } 214 | 215 | /** 216 | * Generate a new token id, byte[16] using a SecureRandom source 217 | * 218 | * @return byte[16] 219 | */ 220 | public static byte[] newTokenID() { 221 | final byte[] id = new byte[16]; 222 | DelegatedRecoveryUtils.secureRandom.nextBytes(id); 223 | return id; 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /sdk/java-src/src/main/java/com/facebook/delegatedrecovery/InvalidOriginException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2016-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the BSD-style license found in the 6 | * LICENSE file in the root directory of this source tree. An additional grant 7 | * of patent rights can be found in the PATENTS file in the same directory. 8 | */ 9 | package com.facebook.delegatedrecovery; 10 | 11 | /** 12 | * Thrown if what should be an RFC6454 Origin is not. (check for disallowed 13 | * trailing slashes) 14 | */ 15 | public class InvalidOriginException extends Exception { 16 | 17 | private static final long serialVersionUID = -8278122378279640808L; 18 | 19 | public InvalidOriginException(String message) { 20 | super(message); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /sdk/java-src/src/main/java/com/facebook/delegatedrecovery/InvalidTokenException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2016-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the BSD-style license found in the 6 | * LICENSE file in the root directory of this source tree. An additional grant 7 | * of patent rights can be found in the PATENTS file in the same directory. 8 | */ 9 | package com.facebook.delegatedrecovery; 10 | 11 | /** 12 | * Thrown if there are problems validating a token. 13 | */ 14 | public class InvalidTokenException extends Exception { 15 | 16 | private static final long serialVersionUID = -8933032394580579696L; 17 | 18 | public InvalidTokenException(final String message) { 19 | super(message); 20 | } 21 | 22 | public InvalidTokenException(final Throwable cause) { 23 | super(cause); 24 | } 25 | 26 | public InvalidTokenException(final String message, final Throwable cause) { 27 | super(message, cause); 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /sdk/java-src/src/main/java/com/facebook/delegatedrecovery/RecoveryProviderConfiguration.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2016-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the BSD-style license found in the 6 | * LICENSE file in the root directory of this source tree. An additional grant 7 | * of patent rights can be found in the PATENTS file in the same directory. 8 | */ 9 | package com.facebook.delegatedrecovery; 10 | 11 | import javax.json.JsonObject; 12 | import java.net.URL; 13 | import java.security.interfaces.ECPublicKey; 14 | import java.util.Base64; 15 | 16 | /** 17 | * Represents the configuration of a RecoveryProvider in the delegated account 18 | * recovery protocol 19 | */ 20 | public class RecoveryProviderConfiguration extends DelegatedRecoveryConfiguration { 21 | 22 | private final ECPublicKey[] pubKeys; 23 | private int tokenMaxSize; 24 | 25 | /** 26 | * @return instantiated EC public keys 27 | */ 28 | public ECPublicKey[] getPubKeys() { 29 | return pubKeys == null ? null : pubKeys.clone(); 30 | } 31 | 32 | /** 33 | * @return max token size, in bytes, a recovery provider is willing to accept 34 | */ 35 | public int getTokenMaxSize() { 36 | return tokenMaxSize; 37 | } 38 | 39 | /** 40 | * @return save-token URL 41 | */ 42 | public URL getSaveToken() { 43 | return saveToken; 44 | } 45 | 46 | /** 47 | * @return recover-account URL 48 | */ 49 | public URL getRecoverAccount() { 50 | return recoverAccount; 51 | } 52 | 53 | /** 54 | * @return save-token-async-api-iframe URL 55 | */ 56 | public URL getSaveTokenAsyncApiIframe() { 57 | return saveTokenAsyncApiIframe; 58 | } 59 | 60 | private URL saveToken; 61 | private URL recoverAccount; 62 | private URL saveTokenAsyncApiIframe; 63 | 64 | /** 65 | * Constructor from raw JSON as published 66 | * 67 | * @param json The JSON blob to pull the data out of 68 | * @throws Exception If the JSON fails to parse of if URL's in the json are invalid. 69 | */ 70 | public RecoveryProviderConfiguration(final JsonObject json) throws Exception { 71 | super(json); 72 | String stString = json.getString("save-token"); 73 | this.saveToken = new URL(stString); 74 | String raString = json.getString("recover-account"); 75 | this.recoverAccount = new URL(raString); 76 | String saveTokenAsyncApiIframe = json.getString("save-token-async-api-iframe"); 77 | this.saveTokenAsyncApiIframe = new URL(saveTokenAsyncApiIframe); 78 | pubKeys = keysFromJsonArray(json.getJsonArray("countersign-pubkeys-secp256r1")); 79 | } 80 | 81 | public String toString() { 82 | StringBuilder out = new StringBuilder(300); 83 | out.append("RecoveryProviderConfiguration: ") 84 | .append("\n issuer: ").append(getIssuer()) 85 | .append("\n privacy-policy: ").append(getPrivacyPolicy()) 86 | .append("\n icon-152px: ").append(getIcon152px()) 87 | .append("\n save-token: ").append(getSaveToken()) 88 | .append("\n recover-account: ").append(getRecoverAccount()) 89 | .append("\n save-token-async-api-iframe: ").append(getSaveTokenAsyncApiIframe()) 90 | .append("\n countersign-pubkeys-secp256r1: [\n"); 91 | for (final ECPublicKey key : pubKeys) { 92 | out.append(" ") 93 | .append(Base64.getEncoder().encodeToString(key.getEncoded())) 94 | .append("\n"); 95 | } 96 | out.append("]\n"); 97 | return out.toString(); 98 | } 99 | 100 | } 101 | -------------------------------------------------------------------------------- /sdk/java-src/src/main/java/com/facebook/delegatedrecovery/RecoveryToken.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2016-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the BSD-style license found in the 6 | * LICENSE file in the root directory of this source tree. An additional grant 7 | * of patent rights can be found in the PATENTS file in the same directory. 8 | */ 9 | package com.facebook.delegatedrecovery; 10 | 11 | import org.bouncycastle.asn1.ASN1Integer; 12 | import org.bouncycastle.asn1.DERSequenceGenerator; 13 | import org.bouncycastle.crypto.digests.SHA256Digest; 14 | import org.bouncycastle.crypto.params.ECPrivateKeyParameters; 15 | import org.bouncycastle.crypto.signers.ECDSASigner; 16 | import org.bouncycastle.crypto.signers.HMacDSAKCalculator; 17 | 18 | import java.io.ByteArrayOutputStream; 19 | import java.io.IOException; 20 | import java.io.UnsupportedEncodingException; 21 | import java.math.BigInteger; 22 | import java.nio.ByteBuffer; 23 | import java.nio.charset.StandardCharsets; 24 | import java.security.*; 25 | import java.security.interfaces.ECPrivateKey; 26 | import java.security.interfaces.ECPublicKey; 27 | import java.util.Arrays; 28 | import java.util.Base64; 29 | 30 | /** 31 | * Represents the recovery token, and serves as a base class for the 32 | * countersigned recovery token, in the delegated account recovery protocol. 33 | */ 34 | public class RecoveryToken { 35 | 36 | /** 37 | * No options for token options field 38 | */ 39 | public static final byte NO_OPTIONS = 0x00; 40 | 41 | /** 42 | * Status callbacks requested token options flag. 43 | */ 44 | public static final byte STATUS_REQUESTED_FLAG = 0x01; 45 | 46 | /** 47 | * Low-friction token recovery requested options flag. 48 | */ 49 | public static final byte LOW_FRICTION_REQUESTED_FLAG = 0x02; 50 | 51 | /** 52 | * Mandatory version field value. 53 | */ 54 | public static final byte VERSION = 0x00; 55 | 56 | /** 57 | * Token type field for recovery token. 58 | */ 59 | public static final byte TYPE_RECOVERY_TOKEN = 0x00; 60 | 61 | /** 62 | * Token type field for countersigned recovery token. 63 | */ 64 | public static final byte TYPE_COUNTERSIGNED_TOKEN = 0x01; 65 | 66 | protected byte type; 67 | protected byte version; 68 | protected byte[] id; 69 | protected byte options; 70 | protected String issuer; 71 | protected String audience; 72 | protected String issuedTime; 73 | protected byte[] data; 74 | protected byte[] binding; 75 | protected byte[] signature; 76 | protected byte[] decoded; 77 | protected String encoded; 78 | 79 | /** 80 | * Construct a RecoveryToken. 81 | * 82 | * @param privateKey The key to sign this token with. 83 | * @param id A unique id for the key. 84 | * @param options A set of bit flags setting options on the token 85 | * @param issuer The RFC-6454 origin of the recovery service 86 | * @param audience The RFC-6454 origin of your service 87 | * @param data Additional data to store in the token, can be null. This data will not be encrypted by this method. 88 | * @param binding token binding string to verify against, usually null 89 | * @throws InvalidOriginException If the issuer or audience is invalid 90 | * @throws IOException If signature fails DER encoding 91 | */ 92 | public RecoveryToken( 93 | final ECPrivateKey privateKey, 94 | final byte[] id, 95 | final byte options, 96 | final String issuer, 97 | final String audience, 98 | final byte[] data, 99 | final byte[] binding) throws InvalidOriginException, IOException { 100 | if (id == null || id.length != 16) { 101 | throw new InvalidParameterException("token id must be byte[16]"); 102 | } 103 | DelegatedRecoveryUtils.validateOrigin(issuer); 104 | DelegatedRecoveryUtils.validateOrigin(audience); 105 | 106 | this.version = VERSION; 107 | this.type = TYPE_RECOVERY_TOKEN; 108 | this.id = id.clone(); 109 | this.options = options; 110 | this.issuer = issuer; 111 | this.audience = audience; 112 | this.data = data.clone(); 113 | this.binding = binding.clone(); 114 | 115 | this.issuedTime = DelegatedRecoveryUtils.nowISO8601(); 116 | 117 | final int tokenLength = 118 | 1 + // uint8 version 119 | 1 + // uint8 type 120 | 16 + // byte[16] token_id 121 | 1 + // uint8 options 122 | 2 + // uint16 issuer_length 123 | issuer.length() + // issuer[issuer_length] 124 | 2 + // uint16 audience_length 125 | audience.length() + // audience[audience_length] 126 | 2 + // uint16 issued_time_length 127 | issuedTime.length() + // issued_time[isued_time_length] 128 | 2 + // uint16 data_length 129 | data.length + // data[data_length] 130 | 2 + // uint16 binding_length 131 | binding.length; // binding[binding_length] 132 | 133 | final byte[] rawToken = new byte[tokenLength]; 134 | 135 | final ByteBuffer tokenBuffer = ByteBuffer.wrap(rawToken); 136 | tokenBuffer 137 | .put(RecoveryToken.VERSION) 138 | .put(RecoveryToken.TYPE_RECOVERY_TOKEN) 139 | .put(id) 140 | .put(options) 141 | .putChar((char) issuer.length()) 142 | .put(issuer.getBytes(StandardCharsets.US_ASCII)) 143 | .putChar((char) audience.length()) 144 | .put(audience.getBytes(StandardCharsets.US_ASCII)) 145 | .putChar((char) issuedTime.length()) 146 | .put(issuedTime.getBytes(StandardCharsets.US_ASCII)) 147 | .putChar((char) data.length) 148 | .put(data) 149 | .putChar((char) binding.length) 150 | .put(binding); 151 | 152 | final byte[] rawArray = rawToken; 153 | 154 | this.signature = getSignature(rawToken, privateKey); 155 | 156 | this.decoded = new byte[rawArray.length + signature.length]; 157 | System.arraycopy(rawArray, 0, decoded, 0, rawArray.length); 158 | System.arraycopy(signature, 0, decoded, rawArray.length, signature.length); 159 | 160 | this.encoded = Base64.getEncoder().encodeToString(decoded); 161 | } 162 | 163 | /** 164 | * Check the signature on a token. 165 | * 166 | * @param keys they keys to validate 167 | * @return whether signature is valid 168 | * @throws InvalidKeyException If the keys are invalid 169 | * @throws SignatureException If the keys are invalid 170 | */ 171 | public boolean isSignatureValid(final ECPublicKey[] keys) throws InvalidKeyException, SignatureException { 172 | try { 173 | final Signature verifier = Signature.getInstance("SHA256withECDSA"); 174 | for (final ECPublicKey key : keys) { 175 | verifier.initVerify(key); 176 | verifier.update(Arrays.copyOfRange(decoded, 0, decoded.length - signature.length)); 177 | if (verifier.verify(signature)) { 178 | return true; 179 | } 180 | } 181 | return false; 182 | } catch (final NoSuchAlgorithmException e) { 183 | throw new Error(e.getMessage()); 184 | } 185 | } 186 | 187 | /** 188 | * Construct a token from an encoded string. This constructor does not 189 | * validate the token signature. 190 | * 191 | * @param encoded Base64 encoded binary token 192 | * @throws InvalidOriginException If the issuer or audience in the token are invalid 193 | */ 194 | public RecoveryToken(final String encoded) throws InvalidOriginException { 195 | try { 196 | this.encoded = encoded; 197 | decoded = Base64.getDecoder().decode(encoded); 198 | 199 | int offset = 0; 200 | version = decoded[offset]; 201 | offset += 1; 202 | type = decoded[offset]; 203 | offset += 1; 204 | id = Arrays.copyOfRange(decoded, offset, offset + 16); 205 | offset += 16; 206 | options = decoded[offset]; 207 | offset += 1; 208 | final int issuerLength = decoded[offset] << 8 & 0xFF00 | decoded[offset + 1] & 0xFF; 209 | offset += 2; 210 | issuer = new String(Arrays.copyOfRange(decoded, offset, offset + issuerLength), "US-ASCII"); 211 | offset += issuerLength; 212 | final int audienceLength = decoded[offset] << 8 & 0xFF00 | decoded[offset + 1] & 0xFF; 213 | offset += 2; 214 | audience = new String(Arrays.copyOfRange(decoded, offset, offset + audienceLength), "US-ASCII"); 215 | offset += audienceLength; 216 | final int issuedTimeLength = decoded[offset] << 8 & 0xFF00 | decoded[offset + 1] & 0xFF; 217 | offset += 2; 218 | issuedTime = new String(Arrays.copyOfRange(decoded, offset, offset + issuedTimeLength), "US-ASCII"); 219 | offset += issuedTimeLength; 220 | final int dataLength = decoded[offset] << 8 & 0xFF00 | decoded[offset + 1] & 0xFF; 221 | offset += 2; 222 | data = Arrays.copyOfRange(decoded, offset, offset + dataLength); 223 | offset += dataLength; 224 | final int bindingLength = decoded[offset] << 8 & 0xFF00 | decoded[offset + 1] & 0xFF; 225 | offset += 2; 226 | binding = Arrays.copyOfRange(decoded, offset, offset + bindingLength); 227 | offset += bindingLength; 228 | signature = Arrays.copyOfRange(decoded, offset, decoded.length); 229 | 230 | commonSanityCheck(); 231 | typedSanityCheck(); 232 | } catch (final UnsupportedEncodingException e) { 233 | throw new Error(e.getMessage()); 234 | } 235 | } 236 | 237 | protected void commonSanityCheck() throws InvalidOriginException { 238 | if (version != VERSION) { 239 | throw new IllegalArgumentException("illegal version"); 240 | } 241 | DelegatedRecoveryUtils.validateOrigin(issuer); 242 | DelegatedRecoveryUtils.validateOrigin(audience); 243 | } 244 | 245 | protected void typedSanityCheck() { 246 | if (type != RecoveryToken.TYPE_COUNTERSIGNED_TOKEN) { 247 | throw new IllegalArgumentException("illegal token type"); 248 | } 249 | } 250 | 251 | private byte[] getSignature(final byte[] rawArray, final ECPrivateKey privateKey) throws IOException { 252 | if (this.signature != null) { 253 | throw new IllegalStateException("This token already has a signature."); 254 | } 255 | final BigInteger privatePoint = privateKey.getS(); 256 | 257 | final SHA256Digest digest = new SHA256Digest(); 258 | final byte[] hash = new byte[digest.getByteLength()]; 259 | digest.update(rawArray, 0, rawArray.length); 260 | digest.doFinal(hash, 0); 261 | 262 | final ECDSASigner signer = new ECDSASigner(new HMacDSAKCalculator(new SHA256Digest())); 263 | signer.init(true, new ECPrivateKeyParameters(privatePoint, DelegatedRecoveryUtils.P256_DOMAIN_PARAMS)); 264 | final BigInteger[] signature = signer.generateSignature(hash); 265 | final ByteArrayOutputStream s = new ByteArrayOutputStream(); 266 | final DERSequenceGenerator seq = new DERSequenceGenerator(s); 267 | seq.addObject(new ASN1Integer(signature[0])); 268 | seq.addObject(new ASN1Integer(signature[1])); 269 | seq.close(); 270 | 271 | return s.toByteArray(); 272 | } 273 | 274 | public byte getType() { 275 | return type; 276 | } 277 | 278 | public byte getVersion() { 279 | return version; 280 | } 281 | 282 | public byte[] getId() { 283 | return id == null ? null : id.clone(); 284 | } 285 | 286 | public byte getOptions() { 287 | return options; 288 | } 289 | 290 | public String getIssuer() { 291 | return issuer; 292 | } 293 | 294 | public String getAudience() { 295 | return audience; 296 | } 297 | 298 | /** 299 | * ISO8601 time string 300 | * @return the issued time 301 | */ 302 | public String getIssuedTime() { 303 | if (this.signature == null) { 304 | throw new IllegalStateException("This token has not been signed. Call getSigned(privateKey) first."); 305 | } 306 | return issuedTime; 307 | } 308 | 309 | public byte[] getData() { 310 | return data == null ? null : data.clone(); 311 | } 312 | 313 | public byte[] getBinding() { 314 | return binding == null ? null : binding.clone(); 315 | } 316 | 317 | public byte[] getSignature() { 318 | return signature == null ? null : signature.clone(); 319 | } 320 | 321 | public String getEncoded() throws IllegalStateException { 322 | return encoded; 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /sdk/js-src/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "rules": { 8 | "indent": [ 9 | "error", 10 | 4 11 | ], 12 | "linebreak-style": [ 13 | "error", 14 | "unix" 15 | ], 16 | "quotes": [ 17 | "error", 18 | "single" 19 | ], 20 | "semi": [ 21 | "error", 22 | "always" 23 | ], 24 | "comma-dangle": [ 25 | "error", 26 | "always-multiline" 27 | ] 28 | }, 29 | "plugins": [ 30 | "json" 31 | ] 32 | } -------------------------------------------------------------------------------- /sdk/js-src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2016-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the BSD-style license found in the 6 | * LICENSE file in the root directory of this source tree. An additional grant 7 | * of patent rights can be found in the PATENTS file in the same directory. 8 | */ 9 | 10 | /** @module delegated-account-recovery */ 11 | /** 12 | * @file index.js 13 | * @copyright Copyright (c) 2016-present, Facebook, Inc. 14 | * This source code is licensed under the BSD-style license found in the 15 | * LICENSE file in the root directory of this source tree. An additional grant 16 | * of patent rights can be found in the PATENTS file in the same directory. 17 | */ 18 | 19 | 'use strict'; 20 | const crypto = require('crypto'), 21 | url = require('url'), 22 | https = require('https'); 23 | 24 | /** well known path for published configuration */ 25 | const CONFIG_PATH = '/.well-known/delegated-account-recovery/configuration'; 26 | 27 | /** well known path for receiving token status callbacks */ 28 | const STATUS_PATH = '/.well-known/delegated-account-recovery/token-status'; 29 | 30 | const originRegex = /^https:\/\/([a-z0-9-]{1,63}\.)+([a-z]{2,63})(:[\d]+)?$/; 31 | 32 | /** 33 | * Class representing a RecoveryToken. 34 | */ 35 | class RecoveryToken { 36 | static get NO_OPTIONS() { return 0x00; } 37 | static get STATUS_REQUESTED_FLAG() { return 0x01; } 38 | static get LOW_FRICTION_REQUESTED_FLAG() { return 0x02; } 39 | static get VERSION() { return 0x00; } 40 | static get TYPE_RECOVERY_TOKEN() { return 0x00; } 41 | static get TYPE_COUNTERSIGNED_TOKEN() { return 0x01; } 42 | 43 | /** 44 | * Create a RecoveryToken 45 | * 46 | * If passing a privateKey, the signature param will be ignored and the token will be signed with that key. 47 | * If privateKey is null, signature should be a buffer and will be set as the token signature. This is 48 | * useful when creating a token from a serialized format. The signature is not validated in this case and 49 | * must be properly checked with isSignatureValid() if this is being used to implement a recovery provider. 50 | * 51 | * @param {string} privateKey - the base64 encoded EC PRIVATE KEY on a single line with no PEM wrapping 52 | * @param {Buffer} id - 16 byte Buffer representing the token id 53 | * @param {number} options - either RecoveryToken.NO_OPTIONS, or a combination of 54 | * RecoveryToken.STATUS_REQUESTED_FLAG and RecoveryToken.LOW_FRICTION_REQUESTED_FLAG 55 | * @param {string} issuer - RFC6454 ASCII encoded origin of the token issuer 56 | * @param {string} audience - RFC6454 ASCII encoded origin of the token audience 57 | * @param {string} issuedTime - ISO8601 ASCII string representing token creation time 58 | * @param {Buffer} data - data to keep in token, encrypted before passing to this method, may be empty 59 | * @param {Buffer} binding - token binding data from the audience, may be empty 60 | * @param {Buffer} [signature] - signature field from a token, if creating from a serialized form 61 | * @throws {Error} if any inputs are malformed 62 | */ 63 | constructor(privateKey, id, options, issuer, audience, issuedTime, data, binding, signature = null) { 64 | if (issuer.search(originRegex)) { throw new Error('malformed issuer'); } 65 | if (audience.search(originRegex)) { throw new Error('malformed audience'); } 66 | if (options && typeof options !== 'number') { throw new Error('malformed options'); } 67 | if (!(id instanceof Buffer) || id.length !== 16) { throw new Error('malformed id'); } 68 | if (data && !(data instanceof Buffer)) { throw new Error('malformed data'); } 69 | if (binding && !(binding instanceof Buffer)) { throw new Error('malformed binding'); } 70 | 71 | this.version = RecoveryToken.VERSION; 72 | this.type = RecoveryToken.TYPE_RECOVERY_TOKEN; 73 | this.id = id || crypto.randomBytes(16); 74 | this.options = options || 0; 75 | this.issuer = issuer; 76 | this.audience = audience; 77 | this.data = data || Buffer.alloc(0); 78 | this.binding = binding || Buffer.alloc(0); 79 | this.issuedTime = issuedTime || new Date().toISOString(); 80 | 81 | const issuerBuf = new Buffer(this.issuer, 'ascii'); 82 | const audienceBuf = new Buffer(this.audience, 'ascii'); 83 | const issuedTimeBuf = new Buffer(this.issuedTime, 'ascii'); 84 | 85 | let tokenLength = 86 | 1 + // uint8 version 87 | 1 + // uint8 type 88 | 16 + // uint64 token_id 89 | 1 + // uint8 options 90 | 2 + // uint16 issuer_length 91 | issuer.length + // issuer[issuer_length] 92 | 2 + // uint16 audience_length 93 | audience.length + // audience[audience_length] 94 | 2 + // uint16 issued_time_length 95 | this.issuedTime.length + // issued_time[isued_time_length] 96 | 2 + // uint16 data_length 97 | data.length + // data[data_length] 98 | 2 + // uint16 binding_length 99 | binding.length; //binding[binding_length] 100 | 101 | let raw = new Buffer(tokenLength); 102 | let offset = 0; 103 | raw.writeUInt8(this.version, offset); 104 | raw.writeUInt8(this.type, offset += 1); 105 | new Buffer(id).copy(raw, offset += 1); 106 | raw.writeUInt8(options, offset += 16); 107 | raw.writeUInt16BE(issuerBuf.length, offset += 1); 108 | issuerBuf.copy(raw, offset += 2); 109 | raw.writeUInt16BE(audienceBuf.length, offset += issuerBuf.length); 110 | audienceBuf.copy(raw, offset += 2); 111 | raw.writeUInt16BE(issuedTimeBuf.length, offset += audienceBuf.length); 112 | issuedTimeBuf.copy(raw, offset += 2); 113 | raw.writeUInt16BE(data.length, offset += issuedTimeBuf.length); 114 | data.copy(raw, offset += 2); 115 | raw.writeUInt16BE(binding.length, offset += data.length); 116 | binding.copy(raw, offset += 2); 117 | offset += binding.length; 118 | this.raw = raw; 119 | if (privateKey === null) { 120 | this.signature = signature; 121 | } else { 122 | const sign = crypto.createSign('sha256'); 123 | sign.update(raw); 124 | this.signature = sign.sign(ecPrivateKeyToPEM(privateKey)); 125 | } 126 | this.encoded = Buffer.concat([this.raw, this.signature]).toString('base64'); 127 | } 128 | 129 | /** 130 | * Deserializes an encoded token and returns an object with the fields. 131 | * @param {Buffer|string} serialized - binary Buffer or Base64 encoded string 132 | * @returns {Object} token fields 133 | */ 134 | static deserialize(serialized) { 135 | let fields = {}; 136 | if(serialized instanceof String) { 137 | serialized = new Buffer(serialized, 'base64'); 138 | } 139 | let offset = 0; 140 | fields.version = serialized.readUInt8(offset); 141 | offset += 1; 142 | fields.type = serialized.readUInt8(offset); 143 | offset += 1; 144 | fields.id = serialized.slice(offset, offset + 16); 145 | offset += 16; 146 | fields.options = serialized.readUInt8(offset); 147 | offset += 1; 148 | let issuerLength = serialized.readUInt16BE(offset); 149 | offset += 2; 150 | fields.issuer = serialized.slice(offset, offset + issuerLength).toString('ascii'); 151 | offset += issuerLength; 152 | let audienceLength = serialized.readUInt16BE(offset); 153 | offset += 2; 154 | fields.audience = serialized.slice(offset, offset + audienceLength).toString('ascii'); 155 | offset += audienceLength; 156 | let issuedTimeLength = serialized.readUInt16BE(offset); 157 | offset += 2; 158 | fields.issuedTime = serialized.slice(offset, offset + issuedTimeLength).toString('ascii'); 159 | offset += issuedTimeLength; 160 | let dataLength = serialized.readUInt16BE(offset); 161 | offset += 2; 162 | fields.data = serialized.slice(offset, offset + dataLength); 163 | offset += dataLength; 164 | let bindingLength = serialized.readUInt16BE(offset); 165 | offset += 2; 166 | fields.binding = serialized.slice(offset, offset + bindingLength); 167 | offset += bindingLength; 168 | fields.raw = serialized.slice(0, offset); 169 | fields.signatureIndex = offset; 170 | fields.signature = serialized.slice(offset, serialized.length); 171 | fields.encoded = serialized.toString('base64'); 172 | return fields; 173 | } 174 | 175 | /** 176 | * Construct a RecoveryToken from a serialized string or Buffer. Does not check signature! 177 | * @param {string|Buffer} serialized - binary Buffer or Base64 encoded serialized string 178 | * @returns {RecoveryToken} 179 | * @throws {Error} if any input fields are invalid 180 | */ 181 | static fromSerialized(serialized) { 182 | let fields = RecoveryToken.deserialize(serialized); 183 | if (fields.version !== RecoveryToken.VERSION) { throw new Error('incorrect version'); } 184 | if (fields.type !== RecoveryToken.TYPE_RECOVERY_TOKEN) { throw new Error('incorrect type'); } 185 | return new RecoveryToken( 186 | null, 187 | fields.id, 188 | fields.options, 189 | fields.issuer, 190 | fields.audience, 191 | fields.issuedTime, 192 | fields.data, 193 | fields.binding, 194 | fields.signature 195 | ); 196 | } 197 | 198 | /** 199 | * Check if the signature on a token is valid. 200 | * @param {string|Buffer} serialized - binary token as Buffer or Base64 encoded string 201 | * @param {string[]} keys - array of base64 encoded EC Public Keys, no newlines or PEM wrapping 202 | * @param {number} [signatureOffset] - start offset of signature (if already known from parsing) 203 | * @return {boolean} 204 | */ 205 | static isSignatureValid(serialized, keys, signatureOffset = null) { 206 | if (serialized instanceof String) { 207 | serialized = new Buffer(serialized, 'base64'); 208 | } 209 | 210 | if (signatureOffset === null) { 211 | signatureOffset = RecoveryToken.deserialize(serialized).signatureOffset; 212 | } 213 | 214 | const raw = serialized.slice(0, signatureOffset); 215 | const sig = serialized.slice(signatureOffset); 216 | 217 | for (let i = 0; i < keys.length; i++) { 218 | let verify = crypto.createVerify('sha256'); 219 | verify.update(raw); 220 | let pem = publicKeyToPEM(keys[i]); 221 | if (verify.verify(pem, sig)) { 222 | return true; 223 | } 224 | } 225 | return false; 226 | } 227 | } 228 | 229 | /** 230 | * Class representing a RecoveryToken. 231 | */ 232 | class CountersignedToken extends RecoveryToken { 233 | constructor(id, options, issuer, audience, issuedTime, data, binding, signature) { 234 | super(null, id, options, issuer, audience, issuedTime, data, binding, signature); 235 | this.type = RecoveryToken.TYPE_COUNTERSIGNED_TOKEN; 236 | } 237 | 238 | /** 239 | * Construct a CountersignedToken from a serialized form. This function requires passing in public keys 240 | * to check the signature of the token and will throw an Error if the signature is invalid. 241 | * @param {string|Buffer} serialized - binary Buffer or Base64 encoded string serialization of the token 242 | * @param {string} issuer - expected issuer, Error thrown if mismatch with serialized token 243 | * @param {string} audience - expected audience, Error thrown if mismatch with serialized token 244 | * @param {number} allowedClockSkew - how many seconds forward or back the issued time can be vs. now, or Error 245 | * @param {Buffer} binding - Buffer of token binding data, can be empty 246 | * @param {string[]} publicKeys - array of base64 encoded EC public keys to check signature. 247 | * @returns {CountersignedToken} 248 | * @throws {Error} if token is invalid, doesn't match expected values or signature validation fails 249 | */ 250 | static fromSerialized(serialized, issuer, audience, allowedClockSkew, binding, publicKeys) { 251 | let fields = RecoveryToken.deserialize(serialized); 252 | if (fields.version !== RecoveryToken.VERSION) { throw new Error('incorrect version'); } 253 | if (fields.type !== RecoveryToken.TYPE_COUNTERSIGNED_TOKEN) { throw new Error('incorrect type'); } 254 | if (fields.issuer !== issuer) { throw new Error('incorrect issuer'); } 255 | if (fields.audience !== audience) { throw new Error('incorrect audience'); } 256 | if (!fields.binding.equals(binding)) { throw new Error('incorrect token binding'); } 257 | 258 | let issuedTime = new Date(fields.issuedTime); 259 | if (Math.abs(issuedTime.value - new Date().value) > (allowedClockSkew * 1000)) { 260 | throw new Error('token issued outside allowed clock skew'); 261 | } 262 | 263 | const token = new CountersignedToken( 264 | fields.id, 265 | fields.options, 266 | fields.issuer, 267 | fields.audience, 268 | fields.issuedTime, 269 | fields.data, 270 | fields.binding, 271 | fields.signature 272 | ); 273 | 274 | if (!RecoveryToken.isSignatureValid(serialized, publicKeys, fields.signatureIndex)) { 275 | throw new Error('invalid countersigned token signature'); 276 | } 277 | 278 | return token; 279 | } 280 | } 281 | 282 | /** 283 | * Helper function to return the hex value of the sha256 digest of the supplied buffer. 284 | * @param {Buffer} buffer - buffer to hash 285 | * @returns {srring} hex encoded digest 286 | */ 287 | function sha256(buffer) { 288 | return new Buffer(crypto.createHash('sha256').update(buffer).digest()).toString('hex'); 289 | } 290 | 291 | /** 292 | * Fetch the delegated account recovery configuration for a given origin, if present 293 | * @param {string} origin - https:// ASCII encoded origin to fetch from 294 | * @param {Object} options - set extras, as per options object used by Node https module 295 | * @returns {Promise} resolves to a configuration object or rejects if fetch or parse fails 296 | */ 297 | function fetchConfiguration(origin, options) { 298 | options = options || {}; 299 | return new Promise((resolve, reject) => { 300 | try { 301 | let u = url.parse(origin + CONFIG_PATH); 302 | options.hostname = u.hostname; 303 | options.path = u.path; 304 | if (u.port) { 305 | options.port = u.port; 306 | } 307 | https.get(options, (res) => { 308 | let body = ''; 309 | res.setEncoding('utf8'); 310 | res.on('data', (d) => { 311 | body += d; 312 | }); 313 | res.on('end', () => { 314 | try { 315 | let json = JSON.parse(body); 316 | json.issuer = json.issuer.toLowerCase(); 317 | if (json.issuer.search(originRegex) != 0) { 318 | reject('Malformed origin for issuer in config: ' + json.issuer); 319 | } 320 | resolve(json); 321 | } catch (e) { 322 | reject('Couldn\'t parse configuration from ' + origin); 323 | } 324 | }); 325 | }).on('error', (e) => { 326 | reject('Couldn\'t fetch configuration from ' + origin + ', error: ' + e); 327 | }); 328 | } catch (e) { 329 | reject('Couldn\'t fetch configuration from ' + origin + ', error: ' + e); 330 | } 331 | }); 332 | } 333 | 334 | /** 335 | * Extracts just the issuer string from a serialized token. 336 | * @param {string} tokenBase64 - the base64 encoded token 337 | * @returns {string} the issuer origin 338 | */ 339 | function extractIssuer(tokenBase64) { 340 | let offset = 19; 341 | let buffer = new Buffer(tokenBase64, 'base64'); 342 | let issuerLength = buffer.readUInt16BE(offset); 343 | offset += 2; 344 | return buffer.slice(offset, offset + issuerLength).toString('ascii'); 345 | } 346 | 347 | /** 348 | * @typedef middlewareOptions 349 | * @type {object} 350 | * @property {string[]} publicKeys - array of base64 encoded public keys used to sign tokens by this service 351 | * @property {string} save-token-return - path of save-token-return endpoint 352 | * @property {string} recover-account-return - path of recover-account-return endpoint 353 | * @property {string} privacy-policy - privacy policy path 354 | * @property {string} icon-152px: icon path 355 | * @property {number} config-max-age: cache-control header max-age value for configuration (default 3600)} 356 | */ 357 | 358 | /** 359 | * @param {middlewareOptions} options 360 | */ 361 | function middleware(options) { 362 | let opts = options || {}; 363 | let kps = options['publicKeys']; 364 | let tokensignPubkeysSecp256r1 = []; 365 | 366 | for (let i = 0; i < kps.length; i++) { 367 | tokensignPubkeysSecp256r1.push(kps[i]); 368 | } 369 | 370 | return (req, res, next) => { 371 | if (req.path === CONFIG_PATH) { 372 | let maxAge = options['config-max-age'] === null ? 3600 // one hour 373 | : options['config-max-age']; 374 | res.set('Cache-Control', `public, max-age=${maxAge}`); 375 | res.set('Access-Control-Allow-Origin', '*'); 376 | res.json({ 377 | 'issuer': 'https://' + opts['issuer'], 378 | 'tokensign-pubkeys-secp256r1': tokensignPubkeysSecp256r1, 379 | 'recover-account-return': `https://${req.headers.host + opts['recover-account-return']}`, 380 | 'save-token-return': `https://${req.headers.host + opts['save-token-return']}`, 381 | 'privacy-policy': `https://${req.headers.host + opts['privacy-policy']}`, 382 | 'icon-152px': `https://${req.headers.host + opts['icon-152px']}`, 383 | }); 384 | } else { 385 | next(); 386 | } 387 | }; 388 | } 389 | 390 | module.exports = { 391 | RecoveryToken: RecoveryToken, 392 | CountersignedToken: CountersignedToken, 393 | middleware: middleware, 394 | fetchConfiguration: fetchConfiguration, 395 | sha256: sha256, 396 | extractIssuer: extractIssuer, 397 | CONFIG_PATH: CONFIG_PATH, 398 | STATUS_PATH: STATUS_PATH, 399 | }; 400 | 401 | /* 402 | * Internal helper functions 403 | */ 404 | 405 | function toPEM(inKey, typeStr) { 406 | let pem = `-----BEGIN ${typeStr}-----\n`; 407 | for (let i = 0; i < inKey.length; i++) { 408 | pem += inKey[i]; 409 | if (((i + 1) % 64) == 0) { 410 | pem += '\n'; 411 | } 412 | } 413 | pem += `\n-----END ${typeStr}-----\n`; 414 | return pem; 415 | } 416 | 417 | function ecPrivateKeyToPEM(inKey) { 418 | return toPEM(inKey, 'EC PRIVATE KEY'); 419 | } 420 | 421 | function publicKeyToPEM(inKey) { 422 | return toPEM(inKey, 'PUBLIC KEY'); 423 | } 424 | --------------------------------------------------------------------------------