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 |
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 |
25 |
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 |
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 |
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 |
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** 
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 |
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 |
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 |
25 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------