├── .codeclimate.yml ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .ruby-version ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Gemfile ├── Guardfile ├── LICENSE ├── Procfile ├── README.md ├── Rakefile ├── app.rb ├── config.ru ├── config ├── database.yml └── initializers │ └── delegated_account_recovery.rb ├── controllers ├── account_provider_controller.rb ├── recovery_provider_controller.rb └── well_known_config_controller.rb ├── darrrr.gemspec ├── db ├── migrate │ ├── 20161010213139_create_tokens.rb │ ├── 20161010214105_add_token_id_to_token.rb │ ├── 20161010214408_add_provider_to_tokens.rb │ └── 20170315000657_separate_tokens.rb └── schema.rb ├── fixtures └── vcr_cassettes │ └── delegated_account_recovery │ ├── integration_test.yml │ └── recovery_provider.yml ├── lib ├── darrrr.rb └── darrrr │ ├── account_provider.rb │ ├── constants.rb │ ├── crypto_helper.rb │ ├── cryptors │ └── default │ │ ├── default_encryptor.rb │ │ ├── encrypted_data.rb │ │ └── encrypted_data_io.rb │ ├── provider.rb │ ├── recovery_provider.rb │ ├── recovery_token.rb │ ├── serialization │ ├── recovery_token_reader.rb │ └── recovery_token_writer.rb │ └── version.rb ├── logos ├── dar-icon-dark.png ├── dar-icon-transparent.png ├── dar-logo-dark.png ├── dar-logo-small.png ├── dar-logo-transparent-small.png ├── dar-logo-transparent.png └── dar-logo.ai ├── models └── token.rb ├── script ├── bootstrap ├── build ├── cibuild ├── server ├── setup └── test ├── spec ├── lib │ ├── darrrr │ │ ├── account_provider_spec.rb │ │ ├── cryptors │ │ │ └── default │ │ │ │ └── encrypted_data_spec.rb │ │ ├── recovery_provider_spec.rb │ │ └── recovery_token_spec.rb │ ├── darrrr_spec.rb │ └── integration │ │ ├── account_provider_controller_spec.rb │ │ ├── integration_spec.rb │ │ └── recovery_provider_controller_spec.rb └── spec_helper.rb └── views ├── index.erb ├── recover_account_return_post.erb ├── recovered.erb ├── recovery_post.erb ├── save_token_failure.erb └── save_token_success.erb /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | bundler-audit: 4 | enabled: false 5 | csslint: 6 | enabled: false 7 | duplication: 8 | enabled: true 9 | config: 10 | languages: 11 | - ruby 12 | eslint: 13 | enabled: false 14 | fixme: 15 | enabled: true 16 | rubocop: 17 | enabled: true 18 | config: 19 | file: .rubocop.yml 20 | ratings: 21 | paths: 22 | - Gemfile.lock 23 | - "**.css" 24 | - "**.inc" 25 | - "**.js" 26 | - "**.jsx" 27 | - "**.module" 28 | - "**.php" 29 | - "**.py" 30 | - "**.rb" 31 | exclude_paths: 32 | - config/ 33 | - db/ 34 | - script/ 35 | - spec/ 36 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Build + Test 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | name: Build + Test 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | ruby: [ '2.4', '2.5', '2.6', '2.7' ] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Setup System 16 | run: | 17 | sudo apt-get install libsqlite3-dev 18 | - name: Set up Ruby ${{ matrix.ruby }} 19 | uses: actions/setup-ruby@v1 20 | with: 21 | ruby-version: ${{ matrix.ruby }} 22 | - name: Build and test with Rake 23 | run: | 24 | gem install bundler 25 | bundle install --jobs 4 --retry 3 --without production 26 | bundle exec rake db:create 27 | bundle exec rake db:schema:load 28 | bundle exec rake db:migrate 29 | bundle exec rspec spec 30 | bundle exec rubocop 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | .bundle/ 3 | .env 4 | log/*.log 5 | pkg/ 6 | test/dummy/db/*.sqlite3 7 | test/dummy/db/*.sqlite3-journal 8 | test/dummy/log/*.log 9 | test/dummy/tmp/ 10 | *.pem 11 | *.gem 12 | aes_key 13 | cookie_secret 14 | *.sqlite3 15 | .DS_Store 16 | Gemfile.lock 17 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | rubocop-github: 3 | - config/default.yml 4 | require: rubocop-performance 5 | GitHub/RailsApplicationRecord: 6 | Enabled: false 7 | Style/FrozenStringLiteralComment: 8 | Enabled: false 9 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.5.7 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at opensource@github.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | [fork]: https://github.com/github/darrrr/fork 4 | [pr]: https://github.com/github/darrrr/compare 5 | [style]: https://github.com/styleguide/ruby 6 | [code-of-conduct]: CODE_OF_CONDUCT.md 7 | 8 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 9 | 10 | Please note that this project is released with a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms. 11 | 12 | ## Submitting a pull request 13 | 14 | 0. [Fork][fork] and clone the repository 15 | 0. Configure and install the dependencies: `script/bootstrap` 16 | 0. Make sure the tests pass on your machine: `rake` 17 | 0. Create a new branch: `git checkout -b my-branch-name` 18 | 0. Make your change, add tests, and make sure the tests still pass 19 | 0. Push to your fork and [submit a pull request][pr] 20 | 0. Pat your self on the back and wait for your pull request to be reviewed and merged. 21 | 22 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 23 | 24 | - Follow the [style guide][style]. 25 | - Write tests. 26 | - Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 27 | - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 28 | 29 | ## Releasing 30 | 31 | 0. Ensure CI is green 32 | 0. Pull the latest code 33 | 0. Increment the [VERSION](https://github.com/github/darrrr/blob/master/lib/darrrr/version.rb) constant 34 | 0. Run `gem build darrrr.gemspec` 35 | 0. Bump the Gemfile and Gemfile.lock versions for an app which relies on this gem 36 | 0. Test behavior locally, branch deploy, whatever needs to happen 37 | 0. Run `bundle exec rake release` 38 | 39 | ## Resources 40 | 41 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 42 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 43 | - [GitHub Help](https://help.github.com) 44 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | gem "activerecord" 8 | gem "dalli" 9 | gem "rack_csrf" 10 | gem "rake" 11 | gem "sinatra" 12 | gem "sinatra-activerecord" 13 | gem "sinatra-contrib" 14 | 15 | group :development do 16 | gem "jdbc-sqlite3", platform: :jruby 17 | gem "pry-nav" 18 | gem "sqlite3", platform: [:ruby, :mswin, :mingw] 19 | end 20 | 21 | group :test do 22 | gem "database_cleaner" 23 | gem "guard-rspec" 24 | gem "mechanize" 25 | gem "poltergeist" 26 | gem "rspec" 27 | gem "rubocop", "< 0.68" 28 | gem "rubocop-github" 29 | gem "rubocop-performance" 30 | gem "ruby_gntp" 31 | gem "simplecov" 32 | gem "simplecov-json" 33 | gem "vcr" 34 | gem "watir" 35 | gem "webmock" 36 | end 37 | 38 | group :production do 39 | gem "pg" 40 | end 41 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | guard :rspec, cmd: "bundle exec rspec", all_after_pass: true do 4 | require "guard/rspec/dsl" 5 | dsl = Guard::RSpec::Dsl.new(self) 6 | 7 | # RSpec files 8 | rspec = dsl.rspec 9 | watch(rspec.spec_helper) { rspec.spec_dir } 10 | watch(rspec.spec_support) { rspec.spec_dir } 11 | watch(rspec.spec_files) 12 | watch("lib/darrrr/provider.rb") { 13 | %w( 14 | spec/lib/darrrr/account_provider_spec.rb 15 | spec/lib/darrrr/recovery_provider_spec.rb 16 | spec/lib/integration/account_provider_controller_spec.rb 17 | spec/lib/integration/recovery_provider_controller_spec.rb 18 | ) 19 | } 20 | dsl.watch_spec_files_for(dsl.ruby.lib_files) 21 | end 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 GitHub 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec rackup config.ru -p $PORT 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Code Climate](https://codeclimate.com/github/github/darrrr/badges/gpa.svg)](https://codeclimate.com/github/github/darrrr) 2 | ![Build + Test](https://github.com/github/darrrr/workflows/Build%20+%20Test/badge.svg?branch=master) 3 | 4 | The Delegated Account Recovery Rigid Reusable Ruby (aka D.a.r.r.r.r. or "Darrrr") library is meant to be used as the fully-complete plumbing in your Rack application when implementing the [Delegated Account Recovery specification](https://github.com/facebook/DelegatedRecoverySpecification). This library is currently used for the implementation at [GitHub](https://githubengineering.com/recover-accounts-elsewhere/). 5 | 6 | Along with a fully featured library, a proof of concept application is provided in this repo. 7 | 8 | ![](/logos/dar-logo-transparent-small.png) 9 | 10 | ## Configuration 11 | 12 | An account provider (e.g. GitHub) is someone who stores a token with someone else (a recovery provider e.g. Facebook) in order to grant access to an account. 13 | 14 | In `config/initializers` or any location that is run during application setup, add a file. **NOTE:** `proc`s are valid values for `countersign_pubkeys_secp256r1` and `tokensign_pubkeys_secp256r1` 15 | 16 | ```ruby 17 | Darrrr.authority = "http://localhost:9292" 18 | Darrrr.privacy_policy = "#{Darrrr.authority}/articles/github-privacy-statement/" 19 | Darrrr.icon_152px = "#{Darrrr.authority}/icon.png" 20 | 21 | # See script/setup for instructions on how to generate keys 22 | Darrrr::AccountProvider.configure do |config| 23 | config.signing_private_key = ENV["ACCOUNT_PROVIDER_PRIVATE_KEY"] 24 | config.symmetric_key = ENV["TOKEN_DATA_AES_KEY"] 25 | config.tokensign_pubkeys_secp256r1 = [ENV["ACCOUNT_PROVIDER_PUBLIC_KEY"]] || lambda { |provider, context| "you wouldn't do this in real life but procs are supported for this value" } 26 | config.save_token_return = "#{Darrrr.authority}/account-provider/save-token-return" 27 | config.recover_account_return = "#{Darrrr.authority}/account-provider/recover-account-return" 28 | end 29 | 30 | Darrrr::RecoveryProvider.configure do |config| 31 | config.signing_private_key = ENV["RECOVERY_PROVIDER_PRIVATE_KEY"] 32 | config.countersign_pubkeys_secp256r1 = [ENV["RECOVERY_PROVIDER_PUBLIC_KEY"]] || lambda { |provider, context| "you wouldn't do this in real life but procs are supported for this value" } 33 | config.token_max_size = 8192 34 | config.save_token = "#{Darrrr.authority}/recovery-provider/save-token" 35 | config.recover_account = "#{Darrrr.authority}/recovery-provider/recover-account" 36 | end 37 | ``` 38 | 39 | The delegated recovery spec depends on publicly available endpoints serving standard configs. These responses can be cached but are not by default. To configure your cache store, provide the reference: 40 | 41 | ```ruby 42 | Darrrr.cache = Dalli::Client.new('localhost:11211', options) 43 | ``` 44 | 45 | The spec disallows `http` URIs for basic security, but sometimes we don't have this setup locally. 46 | 47 | ```ruby 48 | Darrrr.allow_unsafe_urls = true 49 | ``` 50 | 51 | ## Provider registration 52 | 53 | In order to allow a site to act as a provider, it must be "registered" on boot to prevent unauthorized providers from managing tokens. 54 | 55 | ```ruby 56 | # Only configure this if you are acting as a recovery provider 57 | Darrrr.register_account_provider("https://github.com") 58 | 59 | # Only configure this if you are acting as an account provider 60 | Darrrr.register_recovery_provider("https://www.facebook.com") 61 | ``` 62 | 63 | ## Custom crypto 64 | 65 | Create a module that responds to `Module.sign`, `Module.verify`, `Module.decrypt`, and `Module.encrypt`. You can use the template below. I recommend leaving the `#verify` method as is unless you have a compelling reason to override it. 66 | 67 | ### Global config 68 | 69 | Set `Darrrr.this_account_provider.custom_encryptor = MyCustomEncryptor` 70 | Set `Darrrr.this_recovery_provider.custom_encryptor = MyCustomEncryptor` 71 | 72 | ### On-demand 73 | 74 | ```ruby 75 | Darrrr.with_encryptor(MyCustomEncryptor) do 76 | # perform DAR actions using MyCustomEncryptor as the crypto provider 77 | recovery_token, sealed_token = Darrrr.this_account_provider.generate_recovery_token(data: "foo", audience: recovery_provider, context: { user: current_user }) 78 | end 79 | ``` 80 | 81 | ```ruby 82 | module MyCustomEncryptor 83 | class << self 84 | # Encrypts the data in an opaque way 85 | # 86 | # data: the secret to be encrypted 87 | # 88 | # returns a byte array representation of the data 89 | def encrypt(data) 90 | 91 | end 92 | 93 | # Decrypts the data 94 | # 95 | # ciphertext: the byte array to be decrypted 96 | # 97 | # returns a string 98 | def decrypt(ciphertext) 99 | 100 | end 101 | 102 | # payload: binary serialized recovery token (to_binary_s). 103 | # 104 | # key: the private EC key used to sign the token 105 | # 106 | # returns signature in ASN.1 DER r + s sequence 107 | def sign(payload, key) 108 | 109 | end 110 | 111 | # payload: token in binary form 112 | # signature: signature of the binary token 113 | # key: the EC public key used to verify the signature 114 | # 115 | # returns true if signature validates the payload 116 | def verify(payload, signature, key) 117 | # typically, the default verify function should be used to ensure compatibility 118 | Darrrr::DefaultEncryptor.verify(payload, signature, key) 119 | end 120 | end 121 | end 122 | ``` 123 | 124 | ## Example implementation 125 | 126 | I strongly suggest you read the specification, specifically section 3.1 (save-token) and 3.5 (recover account) as they contain the most dangerous operations. 127 | 128 | **NOTE:** this is NOT meant to be a complete implementation, it is just the starting point. Crucial aspects such as authentication, audit logging, out of band notifications, and account provider persistence are not implemented. 129 | 130 | * [Account Provider](controllers/account_provider_controller.rb) (save-token-return, recover-account-return) 131 | * [Recovery Provider](controllers/recovery_provider_controller.rb) (save-token, recover-account) 132 | * [Configuration endpoint](controllers/well_known_config_controller.rb) (`/.well-known/delegated-account-recovery/configuration`) 133 | 134 | Specifically, the gem exposes the following APIs for manipulating tokens. 135 | * Account Provider 136 | * [Generating](https://github.com/github/darrrr/blob/faafda5b1773e077c9c10b55b46216f97d13cd3b/lib/github/delegated_account_recovery/account_provider.rb#L49) a token 137 | * Signing ([`#seal`](https://github.com/github/darrrr/blob/faafda5b1773e077c9c10b55b46216f97d13cd3b/lib/github/delegated_account_recovery/crypto_helper.rb#L13)) a token 138 | * Verifying ([`#unseal`](https://github.com/github/darrrr/blob/faafda5b1773e077c9c10b55b46216f97d13cd3b/lib/github/delegated_account_recovery/crypto_helper.rb#L30)) a countersigned token 139 | * Recovery Provider 140 | * Verifying ([`#unseal`](https://github.com/github/darrrr/blob/faafda5b1773e077c9c10b55b46216f97d13cd3b/lib/github/delegated_account_recovery/crypto_helper.rb#L30)) a token 141 | * [Countersigning](https://github.com/github/darrrr/blob/faafda5b1773e077c9c10b55b46216f97d13cd3b/lib/github/delegated_account_recovery/recovery_provider.rb#L60) a token 142 | 143 | ### Development 144 | 145 | Local development assumes a Mac OS environment with [homebrew](https://brew.sh/) available. Postgres and phantom JS will be installed. 146 | 147 | Run `./script/bootstrap` then run `./script/server` 148 | 149 | * Visit `http://localhost:9292/account-provider` 150 | * (Optionally) Record the random number for verification 151 | * Click "connect to http://localhost:9292" 152 | * You'll see some debug information on the page. 153 | * Click "setup recovery". 154 | * If recovery setup was successful, click "Recovery Setup Successful" 155 | * Click the "recover now?" link 156 | * You'll see an intermediate page, where more debug information is presented. Click "recover token" 157 | * You should be sent back to your host 158 | * And see something like `Recovered data: ` 159 | 160 | ### Tests 161 | 162 | Run `./script/test` to run all tests. 163 | 164 | ## Deploying to heroku 165 | 166 | Use `heroku config:set` to set the environment variables listed in [script/setup](/script/setup). Additionally, run: 167 | 168 | ``` 169 | heroku config:set HOST_URL=$(heroku info -s | grep web_url | cut -d= -f2) 170 | ``` 171 | 172 | Push your app to heroku: 173 | 174 | ``` 175 | git push heroku :master 176 | ``` 177 | 178 | Migrate the database: 179 | 180 | ``` 181 | heroku run rake db:migrate 182 | ``` 183 | 184 | Use the app! 185 | 186 | ``` 187 | heroku restart 188 | heroku open 189 | ``` 190 | 191 | ## Roadmap 192 | 193 | * Add support for `token-status` endpoints as defined by the spec 194 | * Add async API as defined by the spec 195 | * Implement token binding as part of the async API 196 | 197 | ## Don't want to run `./script` entries? 198 | 199 | See `script/setup` for the environment variables that need to be set. 200 | 201 | ## Contributions 202 | 203 | See [CONTRIBUTING.md](CONTRIBUTING.md) 204 | 205 | ## License 206 | 207 | `darrrr` is licensed under the [MIT license](LICENSE.md). 208 | 209 | The MIT license grant is not for GitHub's trademarks, which include the logo designs. GitHub reserves all trademark and copyright rights in and to all GitHub trademarks. GitHub's logos include, for instance, the stylized designs that include "logo" in the file title in the following folder: [logos](/logos). 210 | 211 | GitHub® and its stylized versions and the Invertocat mark are GitHub's Trademarks or registered Trademarks. When using GitHub's logos, be sure to follow the GitHub [logo guidelines](https://github.com/logos). 212 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | # frozen_string_literal: true 3 | 4 | require "bundler/gem_tasks" 5 | require "net/http" 6 | require "net/https" 7 | require "date" 8 | 9 | require_relative "app" 10 | require_relative "lib/darrrr" 11 | require "sinatra/activerecord/rake" 12 | 13 | namespace :db do 14 | task :load_config do 15 | require "./app" 16 | end 17 | end 18 | 19 | 20 | unless ENV["RACK_ENV"] == "production" 21 | require "rspec/core/rake_task" 22 | desc "Run RSpec" 23 | RSpec::Core::RakeTask.new do |t| 24 | t.verbose = false 25 | t.rspec_opts = "--format progress" 26 | end 27 | 28 | task default: :spec 29 | end 30 | 31 | begin 32 | require "rdoc/task" 33 | rescue LoadError 34 | require "rdoc/rdoc" 35 | require "rake/rdoctask" 36 | RDoc::Task = Rake::RDocTask 37 | end 38 | 39 | RDoc::Task.new(:rdoc) do |rdoc| 40 | rdoc.rdoc_dir = "rdoc" 41 | rdoc.title = "SecureHeaders" 42 | rdoc.options << "--line-numbers" 43 | rdoc.rdoc_files.include("lib/**/*.rb") 44 | end 45 | -------------------------------------------------------------------------------- /app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "sinatra" 4 | require "sinatra/multi_route" 5 | require "rack/csrf" 6 | require "json" 7 | require "base64" 8 | require "sinatra/activerecord" 9 | require "pry-nav" if ENV["RACK_ENV"] == "development" 10 | require "dalli" 11 | require "json" 12 | 13 | require_relative "lib/darrrr" 14 | require_relative "config/initializers/delegated_account_recovery" 15 | 16 | class MainController < Sinatra::Base 17 | ACCOUNT_PROVIDER_PATH = "/account-provider" 18 | RECOVERY_PROVIDER_PATH = "/recovery-provider" 19 | 20 | UNAUTHED_ENDPOINTS = [ 21 | "POST:/.well-known/delegated-account-recovery/token-status", 22 | "POST:#{ACCOUNT_PROVIDER_PATH}/recover-account-return", 23 | "POST:#{ACCOUNT_PROVIDER_PATH}/save-token-return", 24 | "POST:#{RECOVERY_PROVIDER_PATH}/recover-account", 25 | "POST:#{RECOVERY_PROVIDER_PATH}/save-token", 26 | ] 27 | 28 | register Sinatra::MultiRoute 29 | register Sinatra::ActiveRecordExtension 30 | 31 | before do 32 | unless request.ssl? 33 | halt 401, "Not authorized\n" if ENV["RACK_ENV"] == :production 34 | end 35 | end 36 | 37 | def notify(message, provider) 38 | # send out of band notifications via email, sms, in-app notifications, smoke signals 39 | end 40 | 41 | def audit(message, token_id) 42 | # allow users and staff to easily verify history 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $stdout.sync = true 4 | require_relative "app" 5 | require_relative "controllers/account_provider_controller" 6 | require_relative "controllers/recovery_provider_controller" 7 | require_relative "controllers/well_known_config_controller" 8 | 9 | configure do 10 | use Rack::Session::Cookie, secret: ENV["COOKIE_SECRET"] 11 | unless Sinatra::Application.environment == :test 12 | use Rack::Csrf, raise: true, skip: MainController::UNAUTHED_ENDPOINTS 13 | end 14 | end 15 | 16 | map(MainController::ACCOUNT_PROVIDER_PATH) { run AccountProviderController } 17 | map(MainController::RECOVERY_PROVIDER_PATH) { run RecoveryProviderController } 18 | map("/") { run WellKnownConfigController } 19 | set :database_file, File.expand_path("../config/database.yml", __FILE__) 20 | 21 | run Sinatra::Application 22 | -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: sqlite3 3 | database: db/development.sqlite3 4 | pool: 5 5 | timeout: 5000 6 | 7 | test: 8 | adapter: sqlite3 9 | database: db/test.sqlite3 10 | pool: 5 11 | timeout: 5000 12 | 13 | production: 14 | database: delegated_account_recovery_production 15 | adapter: postgresql 16 | host: localhost 17 | password: 18 | -------------------------------------------------------------------------------- /config/initializers/delegated_account_recovery.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Darrrr.cache = nil # for caching remote configs 4 | Darrrr.authority = ENV["HOST_URL"] || "http://localhost:9292" 5 | Darrrr.privacy_policy = "#{Darrrr.authority}/articles/github-privacy-statement/" 6 | Darrrr.icon_152px = "#{Darrrr.authority}/icon.png" 7 | 8 | Darrrr::AccountProvider.configure do |config| 9 | config.signing_private_key = ENV["ACCOUNT_PROVIDER_PRIVATE_KEY"] 10 | config.symmetric_key = ENV["TOKEN_DATA_AES_KEY"] 11 | config.tokensign_pubkeys_secp256r1 = lambda do |context| 12 | [ENV["ACCOUNT_PROVIDER_PUBLIC_KEY"]] 13 | end 14 | config.save_token_return = "#{Darrrr.authority}/account-provider/save-token-return" 15 | config.recover_account_return = "#{Darrrr.authority}/account-provider/recover-account-return" 16 | end 17 | 18 | Darrrr::RecoveryProvider.configure do |config| 19 | config.signing_private_key = ENV["RECOVERY_PROVIDER_PRIVATE_KEY"] 20 | config.countersign_pubkeys_secp256r1 = lambda do |context| 21 | [ENV["RECOVERY_PROVIDER_PUBLIC_KEY"]] 22 | end 23 | config.token_max_size = 8192 24 | config.save_token = "#{Darrrr.authority}/recovery-provider/save-token" 25 | config.recover_account = "#{Darrrr.authority}/recovery-provider/recover-account" 26 | end 27 | 28 | Darrrr.register_account_provider(Darrrr.authority) 29 | Darrrr.register_recovery_provider(Darrrr.authority) 30 | 31 | options = { namespace: "app_v1", compress: true } 32 | 33 | # Uncomment to use memcached 34 | # Darrrr.cache = Dalli::Client.new('localhost:11211', options) 35 | 36 | Darrrr.allow_unsafe_urls = true 37 | -------------------------------------------------------------------------------- /controllers/account_provider_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AccountProviderController < MainController 4 | # 1 select recovery provider 5 | get "/" do 6 | erb :index 7 | end 8 | 9 | post "/create" do 10 | recovery_provider = Darrrr.recovery_provider(params["recovery_provider"]) 11 | token, sealed_token = Darrrr.this_account_provider.generate_recovery_token(data: params["phrase"], audience: recovery_provider) 12 | 13 | ReferenceToken.create({ 14 | provider: recovery_provider.origin, 15 | token_id: token.token_id.to_hex, 16 | }) 17 | 18 | audit("token created", token.token_id.to_hex) 19 | 20 | session[:state] = token.state_url 21 | erb :recovery_post, locals: { 22 | state: token.state_url, 23 | endpoint: recovery_provider.save_token, 24 | payload: sealed_token, 25 | token: token, # just for debugging 26 | } 27 | end 28 | 29 | get "/save-token-return" do 30 | if Sinatra::Application.environment != :test && !Rack::Utils.secure_compare(params[:state], session[:state]) 31 | raise "CSRF attack" 32 | end 33 | 34 | # notify the user 35 | # add audit log entry 36 | token_id = Addressable::URI.parse(params[:state]).query_values["id"] 37 | token = ReferenceToken.find_by_token_id!(token_id) 38 | recovery_provider = Darrrr.recovery_provider(token.provider) 39 | 40 | case params[:status] 41 | when "save-success" 42 | token.update_attribute(:confirmed_at, Time.now) 43 | notify("token saved", recovery_provider) 44 | audit("token confirmed", token_id) 45 | erb :save_token_success, locals: { recover_uri: params[:state] } 46 | when "save-failure" 47 | notify("token not saved", recovery_provider) 48 | audit("token unsuccessfully saved", token_id) 49 | erb :save_token_failure 50 | end 51 | end 52 | 53 | route :get, :post, "/recover-account-return" do 54 | unless request.content_type == "application/x-www-form-urlencoded" 55 | halt 400, "Invalid request format" 56 | return 57 | end 58 | countersigned_token = params[:token] 59 | recovery_provider = Darrrr::RecoveryToken.recovery_provider_issuer(Base64.strict_decode64(countersigned_token)) 60 | begin 61 | parsed_token = Darrrr.this_account_provider.validate_countersigned_recovery_token!(countersigned_token) 62 | rescue Darrrr::CountersignedTokenError => e 63 | notify("token recovery unsucessful", recovery_provider) 64 | halt 400, e.message 65 | end 66 | 67 | persisted_token = ReferenceToken.find_by_token_id!(parsed_token.token_id.to_hex) 68 | persisted_token.update_attribute(:recovered_at, Time.now) 69 | notify("token recovered", recovery_provider) 70 | audit("token recovered", parsed_token.token_id) 71 | 72 | erb :recovered, locals: { decrypted_data: parsed_token.decode } 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /controllers/recovery_provider_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../models/token" 4 | 5 | class RecoveryProviderController < MainController 6 | post "/save-token" do 7 | response = { state: params[:state] } 8 | 9 | begin 10 | token = Base64.strict_decode64(params[:token]) 11 | account_provider = Darrrr::RecoveryToken.account_provider_issuer(token) 12 | token = Darrrr.this_recovery_provider.validate_recovery_token!(token) 13 | persisted_token = RecoveryToken.create({ 14 | provider: account_provider.origin, 15 | token_id: token.token_id.to_hex, 16 | token_blob: params[:token] 17 | }) 18 | response[:status] = "save-success" 19 | 20 | audit("token stored with recovery provider", token.token_id.to_hex) 21 | notify("we haz your token", account_provider) 22 | rescue Darrrr::RecoveryTokenError => e 23 | response[:status] = "save-failure" 24 | end 25 | 26 | redirect to("#{account_provider.save_token_return}?#{response.map { |key, value| "#{key}=#{value}" }.join("&")}") 27 | end 28 | 29 | route :get, :post, "/recover-account" do 30 | token = Base64.strict_decode64(RecoveryToken.find_by_token_id(params[:token_id] || params[:id]).token_blob) 31 | 32 | account_provider = Darrrr::RecoveryToken.account_provider_issuer(token) 33 | countersigned_recovery_token = Darrrr.this_recovery_provider.countersign_token(token: token) 34 | 35 | audit("recovery initiated", Darrrr::RecoveryToken.parse(token).token_id.to_hex) 36 | notify("we've countersigned and sent a recovery token", account_provider) 37 | 38 | erb :recover_account_return_post, locals: { 39 | token: countersigned_recovery_token, 40 | recover_account_return_endpoint: account_provider.recover_account_return, 41 | } 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /controllers/well_known_config_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class WellKnownConfigController < MainController 4 | get "/.well-known/delegated-account-recovery/configuration" do 5 | JSON.pretty_generate(Darrrr.account_and_recovery_provider_config) 6 | end 7 | 8 | get "/" do 9 | redirect "/account-provider" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /darrrr.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # frozen_string_literal: true 3 | 4 | require_relative "lib/darrrr/version" 5 | 6 | Gem::Specification.new do |gem| 7 | gem.name = "darrrr" 8 | gem.version = Darrrr::VERSION 9 | gem.licenses = ["MIT"] 10 | 11 | gem.summary = "Client library for the Delegated Recovery spec" 12 | gem.description = "See https://www.facebook.com/notes/protect-the-graph/improving-account-security-with-delegated-recovery/1833022090271267/" 13 | 14 | gem.authors = ["Neil Matatall"] 15 | gem.email = "opensource+darrrr@github.com" 16 | gem.homepage = "http://github.com/github/darrrr" 17 | gem.require_paths = ["lib"] 18 | gem.files = Dir["Rakefile", "{lib}/**/*", "README*", "LICENSE*"] & `git ls-files -z`.split("\0") 19 | 20 | gem.add_dependency("rake") 21 | if RUBY_VERSION > "2.6" 22 | gem.add_dependency("bindata", ">= 2.4.6") # See https://github.com/dmendel/bindata/pull/120 23 | else 24 | gem.add_dependency("bindata") 25 | end 26 | gem.add_dependency("faraday") 27 | gem.add_dependency("addressable") 28 | end 29 | -------------------------------------------------------------------------------- /db/migrate/20161010213139_create_tokens.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateTokens < ActiveRecord::Migration[5.2] 4 | def change 5 | create_table :tokens do |t| 6 | t.string :name 7 | t.text :token_blob 8 | 9 | t.timestamps 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /db/migrate/20161010214105_add_token_id_to_token.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddTokenIdToToken < ActiveRecord::Migration[5.2] 4 | def change 5 | add_column :tokens, :token_id, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20161010214408_add_provider_to_tokens.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddProviderToTokens < ActiveRecord::Migration[5.2] 4 | def change 5 | add_column :tokens, :provider, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20170315000657_separate_tokens.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SeparateTokens < ActiveRecord::Migration[5.2] 4 | def change 5 | rename_table :tokens, :recovery_tokens 6 | create_table :reference_tokens do |t| 7 | t.string :provider 8 | t.string :token_id 9 | t.timestamp :confirmed_at 10 | t.timestamp :recovered_at 11 | t.timestamps 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is auto-generated from the current state of the database. Instead 4 | # of editing this file, please use the migrations feature of Active Record to 5 | # incrementally modify your database, and then regenerate this schema definition. 6 | # 7 | # This file is the source Rails uses to define your schema when running `rails 8 | # db:schema:load`. When creating a new database, `rails db:schema:load` tends to 9 | # be faster and is potentially less error prone than running all of your 10 | # migrations from scratch. Old migrations may fail to apply correctly if those 11 | # migrations use external dependencies or application code. 12 | # 13 | # It's strongly recommended that you check this file into your version control system. 14 | 15 | ActiveRecord::Schema.define(version: 2017_03_15_000657) do 16 | 17 | create_table "recovery_tokens", force: :cascade do |t| 18 | t.string "name" 19 | t.text "token_blob" 20 | t.datetime "created_at", null: false 21 | t.datetime "updated_at", null: false 22 | t.string "token_id" 23 | t.string "provider" 24 | end 25 | 26 | create_table "reference_tokens", force: :cascade do |t| 27 | t.string "provider" 28 | t.string "token_id" 29 | t.datetime "confirmed_at" 30 | t.datetime "recovered_at" 31 | t.datetime "created_at", null: false 32 | t.datetime "updated_at", null: false 33 | end 34 | 35 | end 36 | -------------------------------------------------------------------------------- /fixtures/vcr_cassettes/delegated_account_recovery/integration_test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: get 5 | uri: http://localhost:9292/.well-known/delegated-account-recovery/configuration 6 | body: 7 | encoding: US-ASCII 8 | string: '' 9 | headers: 10 | User-Agent: 11 | - Faraday v0.11.0 12 | Accept-Encoding: 13 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 14 | Accept: 15 | - "*/*" 16 | response: 17 | status: 18 | code: 200 19 | message: 'OK ' 20 | headers: 21 | Content-Type: 22 | - text/html;charset=utf-8 23 | Cache-Control: 24 | - max-age=60 25 | Content-Length: 26 | - '937' 27 | X-Xss-Protection: 28 | - 1; mode=block 29 | X-Content-Type-Options: 30 | - nosniff 31 | X-Frame-Options: 32 | - SAMEORIGIN 33 | Server: 34 | - WEBrick/1.3.1 (Ruby/2.3.1/2016-04-26) 35 | Date: 36 | - Tue, 14 Mar 2017 06:46:26 GMT 37 | Connection: 38 | - Keep-Alive 39 | body: 40 | encoding: UTF-8 41 | string: |- 42 | { 43 | "issuer": "http://localhost:9292", 44 | "countersign-pubkeys-secp256r1": [ 45 | "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEcUoYO9viRDXApcOgjVWlA2e4GTwJV4DzysupSswayKGhZsZMeL2Tlsc4fKkTTyfdRWZ4C1ShO1XQWiowaa1q8w==" 46 | ], 47 | "token-max-size": 8192, 48 | "save-token": "http://localhost:9292/recovery-provider/save-token", 49 | "recover-account": "http://localhost:9292/recovery-provider/recover-account", 50 | "save-token-async-api-iframe": null, 51 | "privacy-policy": "http://localhost:9292/articles/github-privacy-statement/", 52 | "tokensign-pubkeys-secp256r1": [ 53 | "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEks3CjRTWrTnEDEiz36ICsy3mOX7fhauJ3Jj3R6hN7rp0Q6zh3WKIhGMBR8Ccc1VKZ4eMqLmw/WQLHSAn22GD4g==" 54 | ], 55 | "save-token-return": "http://localhost:9292/account-provider/save-token-return", 56 | "recover-account-return": "http://localhost:9292/account-provider/recover-account-return", 57 | "icon-152px": "http://localhost:9292/images/modules/logos_page/GitHub-Mark.png" 58 | } 59 | http_version: 60 | recorded_at: Tue, 14 Mar 2017 06:46:26 GMT 61 | - request: 62 | method: get 63 | uri: https://example-provider.org/.well-known/delegated-account-recovery/configuration 64 | body: 65 | encoding: US-ASCII 66 | string: '' 67 | headers: 68 | Authorization: 69 | - Basic Z2l0aHViOjJiMTc1N2VlYjk3ZjFiNDEwOWYxN2Y2NDQxZmU3NTEy 70 | User-Agent: 71 | - Faraday v0.9.2 72 | response: 73 | status: 74 | code: 200 75 | message: 76 | headers: 77 | strict-transport-security: 78 | - max-age=15552000; preload 79 | cache-control: 80 | - private, no-cache, no-store, must-revalidate 81 | expires: 82 | - Sat, 01 Jan 2000 00:00:00 GMT 83 | access-control-allow-credentials: 84 | - 'true' 85 | x-frame-options: 86 | - DENY 87 | pragma: 88 | - no-cache 89 | vary: 90 | - Origin, Accept-Encoding 91 | access-control-allow-origin: 92 | - https://example-provider.org 93 | access-control-expose-headers: 94 | - X-FB-Debug, X-Loader-Length 95 | public-key-pins-report-only: 96 | - max-age=500; pin-sha256="WoiWRyIOVNa9ihaBciRSC7XHjliYS9VwUGOIud4PB18="; pin-sha256="r/mIkG3eEpVdm+u/ko/cwxzOMo1bk4TyHIlByibiA5E="; 97 | pin-sha256="q4PO2G2cbkZhZ82+JgmRUyGMoAeozA+BSXVXQWB8XWQ="; report-uri="http://reports.fb.com/hpkp/" 98 | access-control-allow-method: 99 | - OPTIONS 100 | x-xss-protection: 101 | - '0' 102 | content-type: 103 | - application/json 104 | x-content-type-options: 105 | - nosniff 106 | x-fb-debug: 107 | - 61gb9jmK8oPlTCKG1ICl2hC9r/SmSK2lN5VjLwPhFzhcVPZ9NRhNQwiFwOAMGCCkn8TdJOLhkbgrH44sRQecsQ== 108 | date: 109 | - Fri, 28 Oct 2016 00:03:57 GMT 110 | transfer-encoding: 111 | - chunked 112 | connection: 113 | - keep-alive 114 | body: 115 | encoding: UTF-8 116 | string: |- 117 | { 118 | "issuer": "https://example-provider.org", 119 | "countersign-pubkeys-secp256r1": [ 120 | "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEt8Q2mx9vXutOdCPlPP0J9qrJs/7aULPCXNyWfwOvt6k9vb2DIVqD3f7HlYOjZTt1xyUVAicfXbiuPA7sp/iaBA==" 121 | ], 122 | "tokensign-pubkeys-secp256r1": [ 123 | "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE+LQRJeAXDpYknpWVn4lEKq0Q1ydH8c7GRcSmOzyLUvOXAxdl11spiqxuw13mHknoTRW0EutMo2gn9ID+uB0WpQ==" 124 | ], 125 | "token-max-size": 8192, 126 | "save-token": "https://example-provider.org/recovery/delegated/save", 127 | "save-token-return": "https://example-provider.org/recovery/delegated/save-token-return", 128 | "recover-account": "https://example-provider.org/recovery/delegated/recover", 129 | "recover-account-return": "https://example-provider.org/recovery/delegated/recover-account-return", 130 | "save-token-async-api-iframe": "https://example-provider.org/plugins/delegated_account_recovery", 131 | "privacy-policy": "https://example-provider.org/about/privacy/", 132 | "icon-152px": "https://example-provider.org/logo" 133 | } 134 | http_version: 135 | recorded_at: Fri, 28 Oct 2016 00:03:57 GMT 136 | recorded_with: VCR 3.0.3 137 | -------------------------------------------------------------------------------- /fixtures/vcr_cassettes/delegated_account_recovery/recovery_provider.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: get 5 | uri: https://example-provider.org/.well-known/delegated-account-recovery/configuration 6 | body: 7 | encoding: US-ASCII 8 | string: '' 9 | headers: 10 | Authorization: 11 | - Basic Z2l0aHViOjJiMTc1N2VlYjk3ZjFiNDEwOWYxN2Y2NDQxZmU3NTEy 12 | User-Agent: 13 | - Faraday v0.9.2 14 | response: 15 | status: 16 | code: 200 17 | message: 18 | headers: 19 | strict-transport-security: 20 | - max-age=15552000; preload 21 | cache-control: 22 | - private, no-cache, no-store, must-revalidate 23 | expires: 24 | - Sat, 01 Jan 2000 00:00:00 GMT 25 | access-control-allow-credentials: 26 | - 'true' 27 | x-frame-options: 28 | - DENY 29 | pragma: 30 | - no-cache 31 | vary: 32 | - Origin, Accept-Encoding 33 | access-control-allow-origin: 34 | - https://example-provider.org 35 | access-control-expose-headers: 36 | - X-FB-Debug, X-Loader-Length 37 | public-key-pins-report-only: 38 | - max-age=500; pin-sha256="WoiWRyIOVNa9ihaBciRSC7XHjliYS9VwUGOIud4PB18="; pin-sha256="r/mIkG3eEpVdm+u/ko/cwxzOMo1bk4TyHIlByibiA5E="; 39 | pin-sha256="q4PO2G2cbkZhZ82+JgmRUyGMoAeozA+BSXVXQWB8XWQ="; report-uri="http://reports.fb.com/hpkp/" 40 | access-control-allow-method: 41 | - OPTIONS 42 | x-xss-protection: 43 | - '0' 44 | content-type: 45 | - application/json 46 | x-content-type-options: 47 | - nosniff 48 | x-fb-debug: 49 | - 61gb9jmK8oPlTCKG1ICl2hC9r/SmSK2lN5VjLwPhFzhcVPZ9NRhNQwiFwOAMGCCkn8TdJOLhkbgrH44sRQecsQ== 50 | date: 51 | - Fri, 28 Oct 2016 00:03:57 GMT 52 | transfer-encoding: 53 | - chunked 54 | connection: 55 | - keep-alive 56 | body: 57 | encoding: UTF-8 58 | string: |- 59 | { 60 | "issuer": "https://example-provider.org", 61 | "countersign-pubkeys-secp256r1": [ 62 | "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEt8Q2mx9vXutOdCPlPP0J9qrJs/7aULPCXNyWfwOvt6k9vb2DIVqD3f7HlYOjZTt1xyUVAicfXbiuPA7sp/iaBA==" 63 | ], 64 | "tokensign-pubkeys-secp256r1": [ 65 | "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE+LQRJeAXDpYknpWVn4lEKq0Q1ydH8c7GRcSmOzyLUvOXAxdl11spiqxuw13mHknoTRW0EutMo2gn9ID+uB0WpQ==" 66 | ], 67 | "token-max-size": 8192, 68 | "save-token": "https://example-provider.org/recovery/delegated/save", 69 | "save-token-return": "https://example-provider.org/recovery/delegated/save-token-return", 70 | "recover-account": "https://example-provider.org/recovery/delegated/recover", 71 | "recover-account-return": "https://example-provider.org/recovery/delegated/recover-account-return", 72 | "save-token-async-api-iframe": "https://example-provider.org/plugins/delegated_account_recovery", 73 | "privacy-policy": "https://example-provider.org/about/privacy/", 74 | "icon-152px": "https://example-provider.org/logo" 75 | } 76 | http_version: 77 | recorded_at: Fri, 28 Oct 2016 00:03:57 GMT 78 | - request: 79 | method: get 80 | uri: http://localhost:9292/.well-known/delegated-account-recovery/configuration 81 | body: 82 | encoding: US-ASCII 83 | string: '' 84 | headers: 85 | Authorization: 86 | - Basic Z2l0aHViOjJiMTc1N2VlYjk3ZjFiNDEwOWYxN2Y2NDQxZmU3NTEy 87 | User-Agent: 88 | - Faraday v0.9.2 89 | response: 90 | status: 91 | code: 200 92 | message: 93 | headers: 94 | strict-transport-security: 95 | - max-age=15552000; preload 96 | cache-control: 97 | - private, no-cache, no-store, must-revalidate 98 | expires: 99 | - Sat, 01 Jan 2000 00:00:00 GMT 100 | access-control-allow-credentials: 101 | - 'true' 102 | x-frame-options: 103 | - DENY 104 | pragma: 105 | - no-cache 106 | vary: 107 | - Origin, Accept-Encoding 108 | access-control-allow-origin: 109 | - http://localhost:9292 110 | access-control-expose-headers: 111 | - X-FB-Debug, X-Loader-Length 112 | public-key-pins-report-only: 113 | - max-age=500; pin-sha256="WoiWRyIOVNa9ihaBciRSC7XHjliYS9VwUGOIud4PB18="; pin-sha256="r/mIkG3eEpVdm+u/ko/cwxzOMo1bk4TyHIlByibiA5E="; 114 | pin-sha256="q4PO2G2cbkZhZ82+JgmRUyGMoAeozA+BSXVXQWB8XWQ="; report-uri="http://reports.fb.com/hpkp/" 115 | access-control-allow-method: 116 | - OPTIONS 117 | x-xss-protection: 118 | - '0' 119 | content-type: 120 | - application/json 121 | x-content-type-options: 122 | - nosniff 123 | x-fb-debug: 124 | - 61gb9jmK8oPlTCKG1ICl2hC9r/SmSK2lN5VjLwPhFzhcVPZ9NRhNQwiFwOAMGCCkn8TdJOLhkbgrH44sRQecsQ== 125 | date: 126 | - Fri, 28 Oct 2016 00:03:57 GMT 127 | transfer-encoding: 128 | - chunked 129 | connection: 130 | - keep-alive 131 | body: 132 | encoding: UTF-8 133 | string: |- 134 | { 135 | "issuer": "http://localhost:9292", 136 | "countersign-pubkeys-secp256r1": [ 137 | "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEcUoYO9viRDXApcOgjVWlA2e4GTwJV4DzysupSswayKGhZsZMeL2Tlsc4fKkTTyfdRWZ4C1ShO1XQWiowaa1q8w==" 138 | ], 139 | "tokensign-pubkeys-secp256r1": [ 140 | "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEks3CjRTWrTnEDEiz36ICsy3mOX7fhauJ3Jj3R6hN7rp0Q6zh3WKIhGMBR8Ccc1VKZ4eMqLmw/WQLHSAn22GD4g==" 141 | ], 142 | "token-max-size": 8192, 143 | "save-token": "http://localhost:9292/settings/security/recovery-provider/save-token", 144 | "save-token-return": "http://localhost:9292/settings/security/delegated-account-recovery/save-token-return", 145 | "recover-account": "http://localhost:9292/settings/security/recovery-provider/recover-account", 146 | "recover-account-return": "http://localhost:9292/settings/security/delegated-account-recovery/recover-account-return", 147 | "save-token-async-api-iframe": "http://localhost:9292/plugins/delegated_account_recovery", 148 | "privacy-policy": "http://localhost:9292/about/privacy/", 149 | "icon-152px": "http://localhost:9292/images/modules/logos_page/GitHub-Mark.png" 150 | } 151 | http_version: 152 | recorded_at: Fri, 28 Oct 2016 00:03:57 GMT 153 | - request: 154 | method: get 155 | uri: https://www.faceboooooook.com/.well-known/delegated-account-recovery/configuration 156 | body: 157 | encoding: US-ASCII 158 | string: '' 159 | headers: 160 | User-Agent: 161 | - Faraday v0.9.2 162 | response: 163 | status: 164 | code: 404 165 | message: 166 | headers: 167 | strict-transport-security: 168 | - max-age=15552000; preload 169 | cache-control: 170 | - private, no-cache, no-store, must-revalidate 171 | expires: 172 | - Sat, 01 Jan 2000 00:00:00 GMT 173 | access-control-allow-credentials: 174 | - 'true' 175 | x-frame-options: 176 | - DENY 177 | pragma: 178 | - no-cache 179 | vary: 180 | - Origin, Accept-Encoding 181 | access-control-allow-origin: 182 | - https://example-provider.org 183 | access-control-expose-headers: 184 | - X-FB-Debug, X-Loader-Length 185 | public-key-pins-report-only: 186 | - max-age=500; pin-sha256="WoiWRyIOVNa9ihaBciRSC7XHjliYS9VwUGOIud4PB18="; pin-sha256="r/mIkG3eEpVdm+u/ko/cwxzOMo1bk4TyHIlByibiA5E="; 187 | pin-sha256="q4PO2G2cbkZhZ82+JgmRUyGMoAeozA+BSXVXQWB8XWQ="; report-uri="http://reports.fb.com/hpkp/" 188 | access-control-allow-method: 189 | - OPTIONS 190 | x-xss-protection: 191 | - '0' 192 | content-type: 193 | - application/json 194 | x-content-type-options: 195 | - nosniff 196 | x-fb-debug: 197 | - 61gb9jmK8oPlTCKG1ICl2hC9r/SmSK2lN5VjLwPhFzhcVPZ9NRhNQwiFwOAMGCCkn8TdJOLhkbgrH44sRQecsQ== 198 | date: 199 | - Fri, 28 Oct 2016 00:03:57 GMT 200 | transfer-encoding: 201 | - chunked 202 | connection: 203 | - keep-alive 204 | body: 205 | encoding: UTF-8 206 | string: "" 207 | http_version: 208 | recorded_at: Fri, 28 Oct 2016 00:03:57 GMT 209 | - request: 210 | method: get 211 | uri: https://bad-json.com/.well-known/delegated-account-recovery/configuration 212 | body: 213 | encoding: US-ASCII 214 | string: '' 215 | headers: 216 | User-Agent: 217 | - Faraday v0.9.2 218 | response: 219 | status: 220 | code: 200 221 | message: 222 | headers: 223 | strict-transport-security: 224 | - max-age=15552000; preload 225 | cache-control: 226 | - private, no-cache, no-store, must-revalidate 227 | expires: 228 | - Sat, 01 Jan 2000 00:00:00 GMT 229 | access-control-allow-credentials: 230 | - 'true' 231 | x-frame-options: 232 | - DENY 233 | pragma: 234 | - no-cache 235 | vary: 236 | - Origin, Accept-Encoding 237 | access-control-allow-origin: 238 | - https://example-provider.org 239 | access-control-expose-headers: 240 | - X-FB-Debug, X-Loader-Length 241 | public-key-pins-report-only: 242 | - max-age=500; pin-sha256="WoiWRyIOVNa9ihaBciRSC7XHjliYS9VwUGOIud4PB18="; pin-sha256="r/mIkG3eEpVdm+u/ko/cwxzOMo1bk4TyHIlByibiA5E="; 243 | pin-sha256="q4PO2G2cbkZhZ82+JgmRUyGMoAeozA+BSXVXQWB8XWQ="; report-uri="http://reports.fb.com/hpkp/" 244 | access-control-allow-method: 245 | - OPTIONS 246 | x-xss-protection: 247 | - '0' 248 | content-type: 249 | - application/json 250 | x-content-type-options: 251 | - nosniff 252 | x-fb-debug: 253 | - 61gb9jmK8oPlTCKG1ICl2hC9r/SmSK2lN5VjLwPhFzhcVPZ9NRhNQwiFwOAMGCCkn8TdJOLhkbgrH44sRQecsQ== 254 | date: 255 | - Fri, 28 Oct 2016 00:03:57 GMT 256 | transfer-encoding: 257 | - chunked 258 | connection: 259 | - keep-alive 260 | body: 261 | encoding: UTF-8 262 | string: |- 263 | { 264 | "issuer": "https://bad-json.org", 265 | "countersign-pubkeys-secp256r1": [ 266 | "" 267 | ], 268 | "token-max-size": 8192, 269 | "save-token": "https://bad-json.org/recovery/delegated/save-token", 270 | "recover-account": "https://bad-json.org/recovery/delegated/recover", 271 | "save-token-async-api-iframe": "https://bad-json.org/plugins/delegated_account_recovery", 272 | "privacy-policy": "https://bad-json.org/about/privacy/""""/////d/e/e/e/e//&*(&(*&())) 273 | } 274 | http_version: 275 | recorded_at: Fri, 28 Oct 2016 00:03:57 GMT 276 | - request: 277 | method: get 278 | uri: https://www.new-provider.com/.well-known/delegated-account-recovery/configuration 279 | body: 280 | encoding: US-ASCII 281 | string: '' 282 | headers: 283 | Authorization: 284 | - Basic Z2l0aHViOjJiMTc1N2VlYjk3ZjFiNDEwOWYxN2Y2NDQxZmU3NTEy 285 | User-Agent: 286 | - Faraday v0.9.2 287 | response: 288 | status: 289 | code: 200 290 | message: 291 | headers: 292 | strict-transport-security: 293 | - max-age=15552000; preload 294 | cache-control: 295 | - private, no-cache, no-store, must-revalidate 296 | expires: 297 | - Sat, 01 Jan 2000 00:00:00 GMT 298 | access-control-allow-credentials: 299 | - 'true' 300 | x-frame-options: 301 | - DENY 302 | pragma: 303 | - no-cache 304 | vary: 305 | - Origin, Accept-Encoding 306 | access-control-allow-origin: 307 | - https://www.new-provider.com 308 | access-control-expose-headers: 309 | - X-FB-Debug, X-Loader-Length 310 | public-key-pins-report-only: 311 | - max-age=500; pin-sha256="WoiWRyIOVNa9ihaBciRSC7XHjliYS9VwUGOIud4PB18="; pin-sha256="r/mIkG3eEpVdm+u/ko/cwxzOMo1bk4TyHIlByibiA5E="; 312 | pin-sha256="q4PO2G2cbkZhZ82+JgmRUyGMoAeozA+BSXVXQWB8XWQ="; report-uri="http://reports.fb.com/hpkp/" 313 | access-control-allow-method: 314 | - OPTIONS 315 | x-xss-protection: 316 | - '0' 317 | content-type: 318 | - application/json 319 | x-content-type-options: 320 | - nosniff 321 | x-fb-debug: 322 | - 61gb9jmK8oPlTCKG1ICl2hC9r/SmSK2lN5VjLwPhFzhcVPZ9NRhNQwiFwOAMGCCkn8TdJOLhkbgrH44sRQecsQ== 323 | date: 324 | - Fri, 28 Oct 2016 00:03:57 GMT 325 | transfer-encoding: 326 | - chunked 327 | connection: 328 | - keep-alive 329 | body: 330 | encoding: UTF-8 331 | string: |- 332 | { 333 | "issuer": "https://www.new-provider.com", 334 | "countersign-pubkeys-secp256r1": [ 335 | "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEgPZXL5SXuMUkJHv/mPLWJSToH6K/1GlDG61RMwRRDSU3nl4Zrb99Zyzy+DrAsEfrqLWIdEt83RmNvwqXELHRlw==" 336 | ], 337 | "token-max-size": 8192, 338 | "save-token": "https://www.new-provider.com/settings/security/recovery-provider/save-token", 339 | "recover-account": "https://www.new-provider.com/settings/security/recovery-provider/recover-account", 340 | "save-token-async-api-iframe": "https://www.new-provider.com/plugins/delegated_account_recovery", 341 | "privacy-policy": "https://www.new-provider.com/about/privacy/", 342 | "icon-152px": "https://www.new-provider.com/images/modules/logos_page/GitHub-Mark.png" 343 | } 344 | http_version: 345 | recorded_at: Fri, 28 Oct 2016 00:03:57 GMT 346 | recorded_with: VCR 2.6.0 347 | -------------------------------------------------------------------------------- /lib/darrrr.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bindata" 4 | require "openssl" 5 | require "addressable" 6 | require "forwardable" 7 | require "faraday" 8 | 9 | require_relative "darrrr/constants" 10 | require_relative "darrrr/crypto_helper" 11 | require_relative "darrrr/recovery_token" 12 | require_relative "darrrr/provider" 13 | require_relative "darrrr/account_provider" 14 | require_relative "darrrr/recovery_provider" 15 | require_relative "darrrr/serialization/recovery_token_writer" 16 | require_relative "darrrr/serialization/recovery_token_reader" 17 | require_relative "darrrr/cryptors/default/default_encryptor" 18 | require_relative "darrrr/cryptors/default/encrypted_data" 19 | require_relative "darrrr/cryptors/default/encrypted_data_io" 20 | 21 | module Darrrr 22 | class DelegatedRecoveryError < StandardError; end 23 | # Represents a binary serialization error 24 | class RecoveryTokenSerializationError < DelegatedRecoveryError; end 25 | 26 | # Represents invalid data within a valid token 27 | # (e.g. wrong `version` number, invalid token `type`) 28 | class TokenFormatError < DelegatedRecoveryError; end 29 | 30 | # Represents all crypto errors 31 | # (e.g. invalid keys, invalid signature, decrypt failures) 32 | class CryptoError < DelegatedRecoveryError; end 33 | 34 | # Represents providers supplying invalid configurations 35 | # (e.g. non-https URLs, missing required fields, http errors) 36 | class ProviderConfigError < DelegatedRecoveryError; end 37 | 38 | # Represents an invalid countersigned recovery token. 39 | # (e.g. invalid signature, invalid nested token, unregistered provider, stale tokens) 40 | class CountersignedTokenError < DelegatedRecoveryError 41 | attr_reader :key 42 | def initialize(message, key) 43 | super(message) 44 | @key = key 45 | end 46 | end 47 | 48 | # Represents an invalid recovery token. 49 | # (e.g. invalid signature, unregistered provider, stale tokens) 50 | class RecoveryTokenError < DelegatedRecoveryError; end 51 | 52 | # Represents a call to to `recovery_provider` or `account_provider` that 53 | # has not been registered. 54 | class UnknownProviderError < DelegatedRecoveryError; end 55 | 56 | include Constants 57 | 58 | class << self 59 | # recovery provider data is only loaded (and cached) upon use. 60 | attr_accessor :recovery_providers, :account_providers, :cache, :allow_unsafe_urls, 61 | :privacy_policy, :icon_152px, :authority, :faraday_config_callback 62 | 63 | # Find and load remote recovery provider configuration data. 64 | # 65 | # provider_origin: the origin that contains the config data in a well-known 66 | # location. 67 | def recovery_provider(provider_origin) 68 | unless self.recovery_providers 69 | raise "No recovery providers configured" 70 | end 71 | 72 | if provider_origin == this_recovery_provider&.origin 73 | this_recovery_provider 74 | elsif self.recovery_providers.include?(provider_origin) 75 | RecoveryProvider.new(provider_origin).load 76 | else 77 | raise UnknownProviderError, "Unknown recovery provider: #{provider_origin}" 78 | end 79 | end 80 | 81 | # Permit an origin to act as a recovery provider. 82 | # 83 | # provider_origin: the origin to permit 84 | def register_recovery_provider(provider_origin) 85 | self.recovery_providers ||= [] 86 | self.recovery_providers << provider_origin 87 | end 88 | 89 | # Find and load remote account provider configuration data. 90 | # 91 | # provider_origin: the origin that contains the config data in a well-known 92 | # location. 93 | def account_provider(provider_origin, &block) 94 | unless self.account_providers 95 | raise "No account providers configured" 96 | end 97 | if provider_origin == this_account_provider&.origin 98 | this_account_provider 99 | elsif self.account_providers.include?(provider_origin) 100 | AccountProvider.new(provider_origin).load 101 | else 102 | raise UnknownProviderError, "Unknown account provider: #{provider_origin}" 103 | end 104 | end 105 | 106 | # Permit an origin to act as an account provider. 107 | # 108 | # account_origin: the origin to permit 109 | def register_account_provider(account_origin) 110 | self.account_providers ||= [] 111 | self.account_providers << account_origin 112 | end 113 | 114 | # Provide a reference to the account provider configuration for this web app 115 | def this_account_provider 116 | AccountProvider.this 117 | end 118 | 119 | # Provide a reference to the recovery provider configuration for this web app 120 | def this_recovery_provider 121 | RecoveryProvider.this 122 | end 123 | 124 | # Returns a hash of all configuration values, recovery and account provider. 125 | def account_and_recovery_provider_config 126 | provider_data = Darrrr.this_account_provider&.to_h || {} 127 | 128 | if Darrrr.this_recovery_provider 129 | provider_data.merge!(recovery_provider_config) do |key, lhs, rhs| 130 | unless lhs == rhs 131 | raise ArgumentError, "inconsistent config value detected #{key}: #{lhs} != #{rhs}" 132 | end 133 | 134 | lhs 135 | end 136 | end 137 | 138 | provider_data 139 | end 140 | 141 | # returns the account provider information in hash form 142 | def account_provider_config 143 | this_account_provider&.to_h 144 | end 145 | 146 | # returns the account provider information in hash form 147 | def recovery_provider_config 148 | this_recovery_provider&.to_h 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /lib/darrrr/account_provider.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Darrrr 4 | class AccountProvider 5 | include CryptoHelper 6 | include Provider 7 | private :seal 8 | 9 | # Only applicable when acting as a recovery provider 10 | PRIVATE_FIELDS = [:symmetric_key, :signing_private_key] 11 | 12 | FIELDS = [:tokensign_pubkeys_secp256r1].freeze 13 | URL_FIELDS = [:issuer, :save_token_return, :recover_account_return, 14 | :privacy_policy, :icon_152px].freeze 15 | 16 | # These are the fields required by the spec 17 | REQUIRED_FIELDS = FIELDS + URL_FIELDS 18 | 19 | attr_writer *REQUIRED_FIELDS 20 | attr_writer *PRIVATE_FIELDS 21 | attr_reader *(REQUIRED_FIELDS - [:tokensign_pubkeys_secp256r1]) 22 | 23 | alias :origin :issuer 24 | 25 | # The CryptoHelper defines an `unseal` method that requires us to 26 | # define a `unseal_keys` method that will return the set of keys that 27 | # are valid when verifying the signature on a sealed key. 28 | # 29 | # returns the value of `tokensign_pubkeys_secp256r1` or executes a proc 30 | # passing `self` as the first argument. 31 | def unseal_keys(context = nil) 32 | if @tokensign_pubkeys_secp256r1.respond_to?(:call) 33 | @tokensign_pubkeys_secp256r1.call(context) 34 | else 35 | @tokensign_pubkeys_secp256r1 36 | end 37 | end 38 | 39 | # Used to serve content at /.well-known/delegated-account-recovery/configuration 40 | def to_h 41 | { 42 | "issuer" => self.issuer, 43 | "tokensign-pubkeys-secp256r1" => self.unseal_keys.dup, 44 | "save-token-return" => self.save_token_return, 45 | "recover-account-return" => self.recover_account_return, 46 | "privacy-policy" => self.privacy_policy, 47 | "icon-152px" => self.icon_152px 48 | } 49 | end 50 | 51 | # Generates a binary token with an encrypted arbitrary data payload. 52 | # 53 | # data: value to encrypt in the token 54 | # provider: the recovery provider/audience of the token 55 | # context: arbitrary data passed on to underlying crypto operations 56 | # options: the value to set for the options byte 57 | # 58 | # returns a [RecoveryToken, b64 encoded sealed_token] tuple 59 | def generate_recovery_token(data:, audience:, context: nil, options: 0x00) 60 | token = RecoveryToken.build(issuer: self, audience: audience, type: RECOVERY_TOKEN_TYPE, options: options) 61 | token.data = self.encryptor.encrypt(data, self, context) 62 | 63 | [token, seal(token, context)] 64 | end 65 | 66 | # Parses a countersigned_token and returns the nested recovery token 67 | # WITHOUT verifying any signatures. This should only be used if no user 68 | # context can be identified or if we're extracting issuer information. 69 | def dangerous_unverified_recovery_token(countersigned_token) 70 | parsed_countersigned_token = RecoveryToken.parse(Base64.strict_decode64(countersigned_token)) 71 | RecoveryToken.parse(parsed_countersigned_token.data) 72 | end 73 | 74 | def encryptor_key 75 | :darrrr_account_provider_encryptor 76 | end 77 | 78 | # Validates the countersigned recovery token by verifying the signature 79 | # of the countersigned token, parsing out the origin recovery token, 80 | # verifying the signature on the recovery token, and finally decrypting 81 | # the data in the origin recovery token. 82 | # 83 | # countersigned_token: our original recovery token wrapped in recovery 84 | # token instance that is signed by the recovery provider. 85 | # context: arbitrary data to be passed to Provider#unseal. 86 | # 87 | # returns a verified recovery token or raises 88 | # an error if the token fails validation. 89 | def validate_countersigned_recovery_token!(countersigned_token, context = {}) 90 | # 5. Validate the the issuer field is present in the token, 91 | # and that it matches the audience field in the original countersigned token. 92 | begin 93 | recovery_provider = RecoveryToken.recovery_provider_issuer(Base64.strict_decode64(countersigned_token)) 94 | rescue RecoveryTokenSerializationError => e 95 | raise CountersignedTokenError.new("Countersigned token is invalid: " + e.message, :countersigned_token_parse_error) 96 | rescue UnknownProviderError => e 97 | raise CountersignedTokenError.new(e.message, :recovery_token_invalid_issuer) 98 | end 99 | 100 | # 1. Parse the countersigned-token. 101 | # 2. Validate that the version field is 0. 102 | # 7. Retrieve the current Recovery Provider configuration as described in Section 2. 103 | # 8. Validate that the counter-signed token signature validates with a current element of the countersign-pubkeys-secp256r1 array. 104 | begin 105 | parsed_countersigned_token = recovery_provider.unseal(Base64.strict_decode64(countersigned_token), context) 106 | rescue TokenFormatError => e 107 | raise CountersignedTokenError.new(e.message, :countersigned_invalid_token_version) 108 | rescue CryptoError 109 | raise CountersignedTokenError.new("Countersigned token has an invalid signature", :countersigned_invalid_signature) 110 | end 111 | 112 | # 3. De-serialize the original recovery token from the data field. 113 | # 4. Validate the signature on the original recovery token. 114 | begin 115 | recovery_token = self.unseal(parsed_countersigned_token.data, context) 116 | rescue RecoveryTokenSerializationError => e 117 | raise CountersignedTokenError.new("Nested recovery token is invalid: " + e.message, :recovery_token_token_parse_error) 118 | rescue TokenFormatError => e 119 | raise CountersignedTokenError.new("Nested recovery token format error: #{e.message}", :recovery_token_invalid_token_type) 120 | rescue CryptoError 121 | raise CountersignedTokenError.new("Nested recovery token has an invalid signature", :recovery_token_invalid_signature) 122 | end 123 | 124 | # 5. Validate the the issuer field is present in the countersigned-token, 125 | # and that it matches the audience field in the original token. 126 | 127 | countersigned_token_issuer = parsed_countersigned_token.issuer 128 | if countersigned_token_issuer.blank? || countersigned_token_issuer != recovery_token.audience || recovery_provider.origin != countersigned_token_issuer 129 | raise CountersignedTokenError.new("Validate the the issuer field is present in the countersigned-token, and that it matches the audience field in the original token", :recovery_token_invalid_issuer) 130 | end 131 | 132 | # 6. Validate the token binding for the countersigned token, if present. 133 | # (the token binding for the inner token is not relevant) 134 | # TODO not required, to be implemented later 135 | 136 | # 9. Decrypt the data field from the original recovery token and parse its information, if present. 137 | # no decryption here is attempted. Attempts to call `decode` will just fail. 138 | 139 | # 10. Apply any additional processing which provider-specific data in the opaque data portion may indicate is necessary. 140 | begin 141 | if DateTime.parse(parsed_countersigned_token.issued_time).utc < (Time.now - CLOCK_SKEW).utc 142 | raise CountersignedTokenError.new("Countersigned recovery token issued at time is too far in the past", :stale_token) 143 | end 144 | rescue ArgumentError 145 | raise CountersignedTokenError.new("Invalid countersigned token issued time", :invalid_issued_time) 146 | end 147 | 148 | recovery_token 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /lib/darrrr/constants.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Darrrr 4 | module Constants 5 | PROTOCOL_VERSION = 0 6 | PRIME_256_V1 = "prime256v1" # AKA secp256r1 7 | GROUP = OpenSSL::PKey::EC::Group.new(PRIME_256_V1) 8 | DIGEST = OpenSSL::Digest::SHA256 9 | TOKEN_ID_BYTE_LENGTH = 16 10 | RECOVERY_TOKEN_TYPE = 0 11 | COUNTERSIGNED_RECOVERY_TOKEN_TYPE = 1 12 | WELL_KNOWN_CONFIG_PATH = ".well-known/delegated-account-recovery/configuration" 13 | CLOCK_SKEW = 5 * 60 14 | end 15 | 16 | include Constants 17 | end 18 | -------------------------------------------------------------------------------- /lib/darrrr/crypto_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Darrrr 4 | module CryptoHelper 5 | include Constants 6 | # Signs the provided token and joins the data with the signature. 7 | # 8 | # token: a RecoveryToken instance 9 | # 10 | # returns a base64 value for the binary token string and the signature 11 | # of the token. 12 | def seal(token, context = nil) 13 | raise RuntimeError, "signing private key must be set" unless self.instance_variable_get(:@signing_private_key) 14 | binary_token = token.to_binary_s 15 | signature = self.encryptor.sign(binary_token, self.instance_variable_get(:@signing_private_key), self, context) 16 | Base64.strict_encode64([binary_token, signature].join) 17 | end 18 | 19 | # Splits the payload by the token size, treats the remaining portion as 20 | # the signature of the payload, and verifies the signature is valid for 21 | # the given payload. 22 | # 23 | # token_and_signature: binary string consisting of [token_binary_str, signature].join 24 | # keys - An array of public keys to use for signature verification. 25 | # 26 | # returns a RecoveryToken if the payload has been verified and 27 | # deserializes correctly. Raises exceptions if any crypto fails. 28 | # Raises an error if the token's version field is not valid. 29 | def unseal(token_and_signature, context = nil) 30 | token = RecoveryToken.parse(token_and_signature) 31 | 32 | unless token.version.to_i == PROTOCOL_VERSION 33 | raise TokenFormatError, "Version field must be #{PROTOCOL_VERSION}" 34 | end 35 | 36 | token_data, signature = partition_signed_token(token_and_signature, token) 37 | self.unseal_keys(context).each do |key| 38 | return token if self.encryptor.verify(token_data, signature, key, self, context) 39 | end 40 | raise CryptoError, "Recovery token signature was invalid" 41 | end 42 | 43 | # Split the binary token into the token data and the signature over the 44 | # data. 45 | # 46 | # token_and_signature: binary serialization of the token and signature for the token 47 | # recovery_token: a RecoveryToken object parsed from token_and_signature 48 | # 49 | # returns a two element array of [token, signature] 50 | private def partition_signed_token(token_and_signature, recovery_token) 51 | token_length = recovery_token.num_bytes 52 | [token_and_signature[0...token_length], token_and_signature[token_length..-1]] 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/darrrr/cryptors/default/default_encryptor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Darrrr 4 | module DefaultEncryptor 5 | class << self 6 | include Constants 7 | 8 | # Encrypts the data in an opaque way 9 | # 10 | # data: the secret to be encrypted 11 | # context: arbitrary data originally passed in via Provider#seal 12 | # 13 | # returns a byte array representation of the data 14 | def encrypt(data, _provider, _context = nil) 15 | EncryptedData.build(data).to_binary_s 16 | end 17 | 18 | # Decrypts the data 19 | # 20 | # ciphertext: the byte array to be decrypted 21 | # context: arbitrary data originally passed in via RecoveryToken#decode 22 | # 23 | # returns a string 24 | def decrypt(ciphertext, _provider, _context = nil) 25 | EncryptedData.parse(ciphertext).decrypt 26 | end 27 | 28 | 29 | # payload: binary serialized recovery token (to_binary_s). 30 | # 31 | # key: the private EC key used to sign the token 32 | # context: arbitrary data originally passed in via Provider#seal 33 | # 34 | # returns signature in ASN.1 DER r + s sequence 35 | def sign(payload, key, _provider, context = nil) 36 | digest = DIGEST.new.digest(payload) 37 | ec = OpenSSL::PKey::EC.new(Base64.strict_decode64(key)) 38 | ec.dsa_sign_asn1(digest) 39 | end 40 | 41 | # payload: token in binary form 42 | # signature: signature of the binary token 43 | # key: the EC public key used to verify the signature 44 | # context: arbitrary data originally passed in via #unseal 45 | # 46 | # returns true if signature validates the payload 47 | def verify(payload, signature, key, _provider, _context = nil) 48 | public_key_hex = format_key(key) 49 | pkey = OpenSSL::PKey::EC.new(GROUP) 50 | public_key_bn = OpenSSL::BN.new(public_key_hex, 16) 51 | public_key = OpenSSL::PKey::EC::Point.new(GROUP, public_key_bn) 52 | pkey.public_key = public_key 53 | 54 | pkey.verify(DIGEST.new, signature, payload) 55 | rescue OpenSSL::PKey::ECError, OpenSSL::PKey::PKeyError => e 56 | raise CryptoError, "Unable verify recovery token" 57 | end 58 | 59 | private def format_key(key) 60 | sequence, bit_string = OpenSSL::ASN1.decode(Base64.decode64(key)).value 61 | unless bit_string.try(:tag) == OpenSSL::ASN1::BIT_STRING 62 | raise CryptoError, "DER-encoded key did not contain a bit string" 63 | end 64 | bit_string.value.unpack("H*").first 65 | rescue OpenSSL::ASN1::ASN1Error => e 66 | raise CryptoError, "Invalid public key format. The key must be in ASN.1 format. #{e.message}" 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/darrrr/cryptors/default/encrypted_data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Darrrr 4 | class EncryptedData 5 | extend Forwardable 6 | CIPHER_OPTIONS = [:encrypt, :decrypt].freeze 7 | CIPHER = "aes-256-gcm".freeze 8 | CIPHER_VERSION = 0 9 | # This is the NIST recommended minimum: http://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf 10 | IV_LENGTH = 12 11 | AUTH_TAG_LENGTH = 16 12 | PROTOCOL_VERSION = 0 13 | 14 | attr_reader :token_object 15 | 16 | def_delegators :@token_object, :version, :iv, :auth_tag, :ciphertext, 17 | :to_binary_s, :num_bytes 18 | 19 | # token_object: an EncryptedDataIO instance 20 | # instance. 21 | def initialize(token_object) 22 | raise TokenFormatError, "Version must be #{PROTOCOL_VERSION}. Supplied: #{token_object.version}" unless token_object.version == CIPHER_VERSION 23 | raise TokenFormatError, "Auth Tag must be 16 bytes" unless token_object.auth_tag.length == AUTH_TAG_LENGTH 24 | raise TokenFormatError, "IV must be 12 bytes" unless token_object.iv.length == IV_LENGTH 25 | @token_object = token_object 26 | end 27 | private_class_method :new 28 | 29 | def decrypt 30 | cipher = self.class.cipher(:decrypt) 31 | cipher.iv = self.iv.to_binary_s 32 | cipher.auth_tag = self.auth_tag.to_binary_s 33 | cipher.auth_data = "" 34 | cipher.update(self.ciphertext.to_binary_s) + cipher.final 35 | rescue OpenSSL::Cipher::CipherError => e 36 | raise CryptoError, "Unable to decrypt data: #{e}" 37 | end 38 | 39 | class << self 40 | # data: the value to encrypt. 41 | # 42 | # returns an EncryptedData instance. 43 | def build(data) 44 | cipher = cipher(:encrypt) 45 | iv = SecureRandom.random_bytes(EncryptedData::IV_LENGTH) 46 | cipher.iv = iv 47 | cipher.auth_data = "" 48 | 49 | ciphertext = cipher.update(data.to_s) + cipher.final 50 | 51 | token = EncryptedDataIO.new.tap do |edata| 52 | edata.version = CIPHER_VERSION 53 | edata.auth_tag = cipher.auth_tag.bytes 54 | edata.iv = iv.bytes 55 | edata.ciphertext = ciphertext.bytes 56 | end 57 | 58 | new(token) 59 | end 60 | 61 | # serialized_data: the binary representation of a token. 62 | # 63 | # returns an EncryptedData instance. 64 | def parse(serialized_data) 65 | data = new(EncryptedDataIO.new.read(serialized_data)) 66 | 67 | # be extra paranoid, oracles and stuff 68 | if data.num_bytes != serialized_data.bytesize 69 | raise CryptoError, "Encypted data field includes unexpected extra bytes" 70 | end 71 | 72 | data 73 | rescue IOError => e 74 | raise RecoveryTokenSerializationError, e.message 75 | end 76 | 77 | # DRY helper for generating cipher objects 78 | def cipher(mode) 79 | unless CIPHER_OPTIONS.include?(mode) 80 | raise ArgumentError, "mode must be `encrypt` or `decrypt`" 81 | end 82 | 83 | OpenSSL::Cipher.new(EncryptedData::CIPHER).tap do |cipher| 84 | cipher.send(mode) 85 | cipher.key = [Darrrr.this_account_provider.instance_variable_get(:@symmetric_key)].pack("H*") 86 | end 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/darrrr/cryptors/default/encrypted_data_io.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Darrrr 4 | class EncryptedDataIO < BinData::Record 5 | uint8 :version 6 | array :auth_tag, type: :uint8, initial_length: EncryptedData::AUTH_TAG_LENGTH 7 | array :iv, type: :uint8, initial_length: EncryptedData::IV_LENGTH 8 | array :ciphertext, type: :uint8, read_until: :eof 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/darrrr/provider.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Darrrr 4 | module Provider 5 | RECOVERY_PROVIDER_CACHE_LENGTH = 60.seconds 6 | MAX_RECOVERY_PROVIDER_CACHE_LENGTH = 5.minutes 7 | REQUIRED_CRYPTO_OPS = [:sign, :verify, :encrypt, :decrypt].freeze 8 | include Constants 9 | 10 | def self.included(base) 11 | base.instance_eval do 12 | # this represents the account/recovery provider on this web app 13 | class << self 14 | attr_accessor :this 15 | 16 | def configure(&block) 17 | raise ArgumentError, "Block required to configure #{self.name}" unless block_given? 18 | raise ProviderConfigError, "#{self.name} already configured" if self.this 19 | self.this = self.new.tap { |provider| provider.instance_eval(&block).freeze } 20 | self.this.privacy_policy = Darrrr.privacy_policy 21 | self.this.icon_152px = Darrrr.icon_152px 22 | self.this.issuer = Darrrr.authority 23 | end 24 | end 25 | end 26 | end 27 | 28 | def initialize(provider_origin = nil, attrs: nil) 29 | self.issuer = provider_origin 30 | load(attrs) if attrs 31 | end 32 | 33 | # Returns the crypto API to be used. A thread local instance overrides the 34 | # globally configured value which overrides the default encryptor. 35 | def encryptor 36 | Thread.current[encryptor_key] || @encryptor || DefaultEncryptor 37 | end 38 | 39 | # Overrides the global `encryptor` API to use 40 | # 41 | # encryptor: a class/module that responds to all +REQUIRED_CRYPTO_OPS+. 42 | def custom_encryptor=(encryptor) 43 | if valid_encryptor?(encryptor) 44 | @encryptor = encryptor 45 | else 46 | raise ArgumentError, "custom encryption class must respond to all of #{REQUIRED_CRYPTO_OPS}" 47 | end 48 | end 49 | 50 | def with_encryptor(encryptor) 51 | raise ArgumentError, "A block must be supplied" unless block_given? 52 | unless valid_encryptor?(encryptor) 53 | raise ArgumentError, "custom encryption class must respond to all of #{REQUIRED_CRYPTO_OPS}" 54 | end 55 | 56 | Thread.current[encryptor_key] = encryptor 57 | yield 58 | ensure 59 | Thread.current[encryptor_key] = nil 60 | end 61 | 62 | private def valid_encryptor?(encryptor) 63 | REQUIRED_CRYPTO_OPS.all? { |m| encryptor.respond_to?(m) } 64 | end 65 | 66 | # Lazily loads attributes if attrs is nil. It makes an http call to the 67 | # recovery provider's well-known config location and caches the response 68 | # if it's valid json. 69 | # 70 | # attrs: optional way of building the provider without making an http call. 71 | def load(attrs = nil) 72 | body = attrs || fetch_config! 73 | set_attrs!(body) 74 | self 75 | end 76 | 77 | private def faraday 78 | Faraday.new do |f| 79 | if Darrrr.faraday_config_callback 80 | Darrrr.faraday_config_callback.call(f) 81 | else 82 | f.adapter(Faraday.default_adapter) 83 | end 84 | end 85 | end 86 | 87 | private def cache_config(response) 88 | match = /max-age=(\d+)/.match(response.headers["cache-control"]) 89 | cache_age = if match 90 | [match[1].to_i, MAX_RECOVERY_PROVIDER_CACHE_LENGTH].min 91 | else 92 | RECOVERY_PROVIDER_CACHE_LENGTH 93 | end 94 | Darrrr.cache.try(:set, cache_key, response.body, cache_age) 95 | end 96 | 97 | private def cache_key 98 | "recovery_provider_config:#{self.origin}:configuration" 99 | end 100 | 101 | private def fetch_config! 102 | unless body = Darrrr.cache.try(:get, cache_key) 103 | response = faraday.get([self.origin, Darrrr::WELL_KNOWN_CONFIG_PATH].join("/")) 104 | if response.success? 105 | cache_config(response) 106 | else 107 | raise ProviderConfigError.new("Unable to retrieve recovery provider config for #{self.origin}: #{response.status}: #{response.body[0..100]}") 108 | end 109 | 110 | body = response.body 111 | end 112 | 113 | JSON.parse(body) 114 | rescue ::JSON::ParserError 115 | raise ProviderConfigError.new("Unable to parse recovery provider config for #{self.origin}:#{body[0..100]}") 116 | end 117 | 118 | private def set_attrs!(context) 119 | self.class::REQUIRED_FIELDS.each do |attr| 120 | value = context[attr.to_s.tr("_", "-")] 121 | self.instance_variable_set("@#{attr}", value) 122 | end 123 | 124 | if errors.any? 125 | raise ProviderConfigError.new("Unable to parse recovery provider config for #{self.origin}: #{errors.join(", ")}") 126 | end 127 | end 128 | 129 | private def errors 130 | errors = [] 131 | self.class::REQUIRED_FIELDS.each do |field| 132 | unless self.instance_variable_get("@#{field}") 133 | errors << "#{field} not set" 134 | end 135 | end 136 | 137 | self.class::URL_FIELDS.each do |field| 138 | begin 139 | uri = Addressable::URI.parse(self.instance_variable_get("@#{field}")) 140 | if !Darrrr.allow_unsafe_urls && uri.try(:scheme) != "https" 141 | errors << "#{field} must be an https URL" 142 | end 143 | rescue Addressable::URI::InvalidURIError 144 | errors << "#{field} must be a valid URL" 145 | end 146 | end 147 | 148 | if self.is_a? RecoveryProvider 149 | unless self.token_max_size.to_i > 0 150 | errors << "token max size must be an integer" 151 | end 152 | end 153 | 154 | unless self.unseal_keys.try(:any?) 155 | errors << "No public key provided" 156 | end 157 | 158 | errors 159 | end 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /lib/darrrr/recovery_provider.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Darrrr 4 | class RecoveryProvider 5 | include Provider 6 | include CryptoHelper 7 | 8 | INTEGER_FIELDS = [:token_max_size] 9 | BASE64_FIELDS = [:countersign_pubkeys_secp256r1] 10 | URL_FIELDS = [ 11 | :issuer, :save_token, 12 | :recover_account, :privacy_policy 13 | ] 14 | REQUIRED_FIELDS = URL_FIELDS + INTEGER_FIELDS + BASE64_FIELDS 15 | 16 | attr_reader *(REQUIRED_FIELDS - [:countersign_pubkeys_secp256r1]) 17 | attr_writer *REQUIRED_FIELDS 18 | attr_writer :signing_private_key, :token_max_size 19 | attr_accessor :save_token_async_api_iframe # optional 20 | 21 | alias :origin :issuer 22 | 23 | # optional field 24 | attr_accessor :icon_152px 25 | 26 | # Used to serve content at /.well-known/delegated-account-recovery/configuration 27 | def to_h 28 | { 29 | "issuer" => self.issuer, 30 | "countersign-pubkeys-secp256r1" => self.unseal_keys.dup, 31 | "token-max-size" => self.token_max_size, 32 | "save-token" => self.save_token, 33 | "recover-account" => self.recover_account, 34 | "save-token-async-api-iframe" => self.save_token_async_api_iframe, 35 | "privacy-policy" => self.privacy_policy 36 | } 37 | end 38 | 39 | # The CryptoHelper defines an `unseal` method that requires us to define 40 | # a `unseal_keys` method that will return the set of keys that are valid 41 | # when verifying the signature on a sealed key. 42 | # 43 | # returns the value of `countersign_pubkeys_secp256r1` or executes a proc 44 | # passing `self` as the first argument. 45 | def unseal_keys(context = nil) 46 | if @countersign_pubkeys_secp256r1.respond_to?(:call) 47 | @countersign_pubkeys_secp256r1.call(context) 48 | else 49 | @countersign_pubkeys_secp256r1 50 | end 51 | end 52 | 53 | # The URL representing the location of the token. Used to initiate a recovery. 54 | # 55 | # token_id: the shared ID representing a token. 56 | def recovery_url(token_id) 57 | [self.recover_account, "?token_id=", URI.escape(token_id)].join 58 | end 59 | 60 | def encryptor_key 61 | :darrrr_recovery_provider_encryptor 62 | end 63 | 64 | # Takes a binary representation of a token and signs if for a given 65 | # account provider. Do not pass in a RecoveryToken object. The wrapping 66 | # data structure is identical to the structure it's wrapping in format. 67 | # 68 | # token: the to_binary_s or binary representation of the recovery token 69 | # context: an arbitrary object that is passed to lower level crypto operations 70 | # options: the value to set in the options byte field of the recovery 71 | # token (defaults to 0x00) 72 | # 73 | # returns a Base64 encoded representation of the countersigned token 74 | # and the signature over the token. 75 | def countersign_token(token:, context: nil, options: 0x00) 76 | begin 77 | account_provider = RecoveryToken.account_provider_issuer(token) 78 | rescue RecoveryTokenSerializationError, UnknownProviderError 79 | raise TokenFormatError, "Could not determine provider" 80 | end 81 | 82 | counter_recovery_token = RecoveryToken.build( 83 | issuer: self, 84 | audience: account_provider, 85 | type: COUNTERSIGNED_RECOVERY_TOKEN_TYPE, 86 | options: options, 87 | ) 88 | 89 | counter_recovery_token.data = token 90 | seal(counter_recovery_token, context) 91 | end 92 | 93 | # Validate the token according to the processing instructions for the 94 | # save-token endpoint. 95 | # 96 | # Returns a validated token 97 | def validate_recovery_token!(token, context = {}) 98 | errors = [] 99 | 100 | # 1. Authenticate the User. The exact nature of how the Recovery Provider authenticates the User is beyond the scope of this specification. 101 | # handled in before_filter 102 | 103 | # 4. Retrieve the Account Provider configuration as described in Section 2 using the issuer field of the token as the subject. 104 | begin 105 | account_provider = RecoveryToken.account_provider_issuer(token) 106 | rescue RecoveryTokenSerializationError, UnknownProviderError, TokenFormatError => e 107 | raise RecoveryTokenError, "Could not determine provider: #{e.message}" 108 | end 109 | 110 | # 2. Parse the token. 111 | # 3. Validate that the version value is 0. 112 | # 5. Validate the signature over the token according to processing rules for the algorithm implied by the version. 113 | begin 114 | recovery_token = account_provider.unseal(token, context) 115 | rescue CryptoError => e 116 | raise RecoveryTokenError.new("Unable to verify signature of token") 117 | rescue TokenFormatError => e 118 | raise RecoveryTokenError.new(e.message) 119 | end 120 | 121 | # 6. Validate that the audience field of the token identifies an origin which the provider considers itself authoritative for. (Often the audience will be same-origin with the Recovery Provider, but other values may be acceptable, e.g. "https://mail.example.com" and "https://social.example.com" may be acceptable audiences for "https://recovery.example.com".) 122 | unless self.origin == recovery_token.audience 123 | raise RecoveryTokenError.new("Unnacceptable audience") 124 | end 125 | 126 | if DateTime.parse(recovery_token.issued_time).utc < (Time.now - CLOCK_SKEW).utc 127 | raise RecoveryTokenError.new("Issued at time is too far in the past") 128 | end 129 | 130 | recovery_token 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /lib/darrrr/recovery_token.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Handles binary serialization/deserialization of recovery token data. It does 4 | # not manage signing/verification of tokens. 5 | # Only account providers will ever call the decode function 6 | module Darrrr 7 | class RecoveryToken 8 | extend Forwardable 9 | 10 | attr_reader :token_object 11 | 12 | def_delegators :@token_object, :token_id, :issuer, :issued_time, :options, 13 | :audience, :binding_data, :data, :version, :to_binary_s, :num_bytes, 14 | :data=, :token_type=, :token_type, :options= 15 | 16 | BASE64_CHARACTERS = /\A[0-9a-zA-Z+\/=]+\z/ 17 | 18 | # Typically, you would not call `new` directly but instead use `build` 19 | # and `parse` 20 | # 21 | # token_object: a RecoveryTokenWriter/RecoveryTokenReader instance 22 | def initialize(token_object) 23 | @token_object = token_object 24 | end 25 | private_class_method :new 26 | 27 | def decode(context = nil) 28 | Darrrr.this_account_provider.encryptor.decrypt(self.data, Darrrr.this_account_provider, context) 29 | end 30 | 31 | # A globally known location of the token, used to initiate a recovery 32 | def state_url 33 | [Darrrr.recovery_provider(self.audience).recover_account, "id=#{CGI::escape(token_id.to_hex)}"].join("?") 34 | end 35 | 36 | class << self 37 | # data: the value that will be encrypted by EncryptedData. 38 | # audience: the provider for which we are building the token. 39 | # type: Either 0 (recovery token) or 1 (countersigned recovery token) 40 | # options: the value to set for the options byte 41 | # 42 | # returns a RecoveryToken. 43 | def build(issuer:, audience:, type:, options: 0x00) 44 | token = RecoveryTokenWriter.new.tap do |token| 45 | token.token_id = token_id 46 | token.issuer = issuer.origin 47 | token.issued_time = Time.now.utc.iso8601 48 | token.options = options 49 | token.audience = audience.origin 50 | token.version = Darrrr::PROTOCOL_VERSION 51 | token.token_type = type 52 | end 53 | new(token) 54 | end 55 | 56 | # token ID generates a random array of bytes. 57 | # this method only exists so that it can be stubbed. 58 | def token_id 59 | SecureRandom.random_bytes(16).bytes.to_a 60 | end 61 | 62 | # serialized_data: a binary string representation of a RecoveryToken. 63 | # 64 | # returns a RecoveryToken. 65 | def parse(serialized_data) 66 | new RecoveryTokenReader.new.read(serialized_data) 67 | rescue IOError => e 68 | message = e.message 69 | if serialized_data =~ BASE64_CHARACTERS 70 | message = "#{message}: did you forget to Base64.strict_decode64 this value?" 71 | end 72 | raise RecoveryTokenSerializationError, message 73 | end 74 | 75 | # Extract a recovery provider from a token based on the token type. 76 | # 77 | # serialized_data: a binary string representation of a RecoveryToken. 78 | # 79 | # returns the recovery provider for the coutnersigned token or raises an 80 | # error if the token is a recovery token 81 | def recovery_provider_issuer(serialized_data) 82 | issuer(serialized_data, Darrrr::COUNTERSIGNED_RECOVERY_TOKEN_TYPE) 83 | end 84 | 85 | # Extract an account provider from a token based on the token type. 86 | # 87 | # serialized_data: a binary string representation of a RecoveryToken. 88 | # 89 | # returns the account provider for the recovery token or raises an error 90 | # if the token is a countersigned token 91 | def account_provider_issuer(serialized_data) 92 | issuer(serialized_data, Darrrr::RECOVERY_TOKEN_TYPE) 93 | end 94 | 95 | # Convenience method to find the issuer of the token 96 | # 97 | # serialized_data: a binary string representation of a RecoveryToken. 98 | # 99 | # raises an error if the token is the not the expected type 100 | # returns the account provider or recovery provider instance based on the 101 | # token type 102 | private def issuer(serialized_data, token_type) 103 | parsed_token = parse(serialized_data) 104 | raise TokenFormatError, "Token type must be #{token_type}" unless parsed_token.token_type == token_type 105 | 106 | issuer = parsed_token.issuer 107 | case token_type 108 | when Darrrr::RECOVERY_TOKEN_TYPE 109 | Darrrr.account_provider(issuer) 110 | when Darrrr::COUNTERSIGNED_RECOVERY_TOKEN_TYPE 111 | Darrrr.recovery_provider(issuer) 112 | else 113 | raise RecoveryTokenError, "Could not determine provider" 114 | end 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/darrrr/serialization/recovery_token_reader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Darrrr 4 | class RecoveryTokenReader < BinData::Record 5 | uint8 :version 6 | uint8 :token_type 7 | array :token_id, type: :uint8, read_until: lambda { index + 1 == Darrrr::TOKEN_ID_BYTE_LENGTH } 8 | uint8 :options 9 | uint16be :issuer_length 10 | string :issuer, read_length: :issuer_length 11 | uint16be :audience_length 12 | string :audience, read_length: :audience_length 13 | uint16be :issued_time_length 14 | string :issued_time, read_length: :issued_time_length 15 | uint16be :data_length 16 | string :data, read_length: :data_length 17 | uint16be :binding_data_length 18 | string :binding_data, read_length: :binding_data_length 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/darrrr/serialization/recovery_token_writer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Darrrr 4 | class RecoveryTokenWriter < BinData::Record 5 | uint8 :version 6 | uint8 :token_type 7 | array :token_id, type: :uint8, initial_length: Darrrr::TOKEN_ID_BYTE_LENGTH 8 | uint8 :options 9 | uint16be :issuer_length, value: lambda { issuer.length } 10 | string :issuer 11 | uint16be :audience_length, value: lambda { audience.length } 12 | string :audience 13 | uint16be :issued_time_length, value: lambda { issued_time.length } 14 | string :issued_time 15 | uint16be :data_length, value: lambda { data.length } 16 | string :data 17 | uint16be :binding_data_length, value: lambda { binding_data.length } 18 | string :binding_data 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/darrrr/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Darrrr 4 | VERSION = "0.1.6" 5 | end 6 | -------------------------------------------------------------------------------- /logos/dar-icon-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/darrrr/817a774d7627afb7e59fe50ef635543306089d48/logos/dar-icon-dark.png -------------------------------------------------------------------------------- /logos/dar-icon-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/darrrr/817a774d7627afb7e59fe50ef635543306089d48/logos/dar-icon-transparent.png -------------------------------------------------------------------------------- /logos/dar-logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/darrrr/817a774d7627afb7e59fe50ef635543306089d48/logos/dar-logo-dark.png -------------------------------------------------------------------------------- /logos/dar-logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/darrrr/817a774d7627afb7e59fe50ef635543306089d48/logos/dar-logo-small.png -------------------------------------------------------------------------------- /logos/dar-logo-transparent-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/darrrr/817a774d7627afb7e59fe50ef635543306089d48/logos/dar-logo-transparent-small.png -------------------------------------------------------------------------------- /logos/dar-logo-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/darrrr/817a774d7627afb7e59fe50ef635543306089d48/logos/dar-logo-transparent.png -------------------------------------------------------------------------------- /logos/dar-logo.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/darrrr/817a774d7627afb7e59fe50ef635543306089d48/logos/dar-logo.ai -------------------------------------------------------------------------------- /models/token.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RecoveryToken < ActiveRecord::Base 4 | end 5 | 6 | class ReferenceToken < ActiveRecord::Base 7 | end 8 | -------------------------------------------------------------------------------- /script/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | bundle install -j 16 --without production 5 | 6 | if [ "$DAR_ENV" != "ci" ]; then 7 | brew install phantomjs 8 | fi 9 | 10 | . script/setup 11 | 12 | bundle exec rake db:create 13 | bundle exec rake db:migrate 14 | RACK_ENV=test bundle exec rake db:migrate 15 | -------------------------------------------------------------------------------- /script/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | cd "$(dirname "$0")/.." 5 | 6 | gem build darrrr.gemspec 7 | -------------------------------------------------------------------------------- /script/cibuild: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | export DAR_ENV=ci 5 | 6 | script/bootstrap 7 | script/test 8 | -------------------------------------------------------------------------------- /script/server: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | cd "$(dirname "$0")/.." 5 | . script/setup 6 | bundle exec rackup 7 | -------------------------------------------------------------------------------- /script/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [ ! -f prime256v1-key.pem ]; then 6 | openssl ecparam -name prime256v1 -genkey -noout -out prime256v1-key.pem 7 | openssl ec -in prime256v1-key.pem -pubout -out prime256v1-pub.pem 8 | fi 9 | 10 | if [ ! -f prime256v1-key-countersign.pem ]; then 11 | openssl ecparam -name prime256v1 -genkey -noout -out prime256v1-key-countersign.pem 12 | openssl ec -in prime256v1-key-countersign.pem -pubout -out prime256v1-pub-countersign.pem 13 | fi 14 | 15 | if [ ! -f aes_key ]; then 16 | openssl rand -hex 32 > aes_key 17 | fi 18 | 19 | if [ ! -f cookie_secret ]; then 20 | openssl rand -hex 32 > cookie_secret 21 | fi 22 | 23 | export RECOVERY_PROVIDER_PRIVATE_KEY=`perl -p -e 's/\R//g; s/-----[\w\s]+-----//' prime256v1-key-countersign.pem` 24 | export ACCOUNT_PROVIDER_PRIVATE_KEY=`perl -p -e 's/\R//g; s/-----[\w\s]+-----//' prime256v1-key.pem` 25 | export RECOVERY_PROVIDER_PUBLIC_KEY=`perl -p -e 's/\R//g; s/-----[\w\s]+-----//' prime256v1-pub-countersign.pem` 26 | export ACCOUNT_PROVIDER_PUBLIC_KEY=`perl -p -e 's/\R//g; s/-----[\w\s]+-----//' prime256v1-pub.pem` 27 | export TOKEN_DATA_AES_KEY=`cat aes_key` 28 | export COOKIE_SECRET=`cat cookie_secret` 29 | -------------------------------------------------------------------------------- /script/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | export RACK_ENV="test" 5 | . script/setup 6 | bundle exec rspec 7 | -------------------------------------------------------------------------------- /spec/lib/darrrr/account_provider_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../../spec_helper" 4 | 5 | module Darrrr 6 | describe AccountProvider, vcr: { cassette_name: "delegated_account_recovery/recovery_provider" } do 7 | let(:recovery_provider) { example_recovery_provider } 8 | let(:account_provider) { AccountProvider.this } 9 | let(:token) { account_provider.generate_recovery_token(data: "hai", audience: recovery_provider).first } 10 | 11 | # Generate a random key that isn't used during the sealing process. 12 | # openssl ecparam -name prime256v1 -genkey -noout -out prime256v1-key.pem 13 | # openssl ec -in prime256v1-key.pem -pubout -out prime256v1-pub.pem 14 | # cat prime256v1-pub.pem 15 | let(:unused_unseal_key) { "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAElAcL7Ki9+1QaGjZE6CLmfbEVqWjGYIm90rDp/Qy+kRCUyW6l5XSuffQyMWwq8rRMONmNzUk4rDgJr1hepp0y5w==" } 16 | 17 | def raw_token(recovery_token: nil, issuer: nil, version: nil, issued_time: nil, data: nil) 18 | RecoveryTokenWriter.new.tap do |token| 19 | token.token_id = SecureRandom.random_bytes(16).bytes.to_a 20 | token.issuer = issuer || recovery_provider.origin 21 | token.issued_time = issued_time || Time.now.utc.iso8601 22 | token.options = 0 # when the token-status endpoint is implemented, change this to 1 23 | token.audience = account_provider.origin 24 | token.token_type = COUNTERSIGNED_RECOVERY_TOKEN_TYPE 25 | token.version = version || PROTOCOL_VERSION 26 | token.data = data || Base64.strict_decode64(account_provider.send(:seal, recovery_token)) 27 | end 28 | end 29 | 30 | it "provides extra options for faraday" do 31 | Darrrr.faraday_config_callback = lambda do |faraday| 32 | faraday.headers["Accept-Encoding"] = "foo" 33 | faraday.adapter(Faraday.default_adapter) 34 | end 35 | account_provider = example_account_provider 36 | 37 | # assert extra header 38 | account_provider_faraday = account_provider.send(:faraday) 39 | expect(account_provider_faraday.headers).to include("Accept-Encoding" => "foo") 40 | 41 | # assert handler is property set 42 | handler = JSON.parse(account_provider_faraday.to_json)["builder"]["adapter"] 43 | expect(handler).to_not be_nil 44 | handler_name = handler["name"] 45 | expect(handler_name).to_not be_nil 46 | expect(handler_name).to eq("Faraday::Adapter::NetHttp") 47 | end 48 | 49 | it "tokens can be sealed and unsealed" do 50 | payload = Base64.strict_decode64(account_provider.send(:seal, token)) 51 | unsealed_token = account_provider.unseal(payload) 52 | expect(token.token_object).to eq(unsealed_token.token_object) 53 | expect("hai").to eq(unsealed_token.decode) 54 | end 55 | 56 | it "tokens can be sealed and unsealed when there are multiple unseal keys" do 57 | expect(account_provider).to receive(:unseal_keys).and_return( 58 | [account_provider.unseal_keys[0], unused_unseal_key] 59 | ).at_least(:once) 60 | payload = Base64.strict_decode64(account_provider.send(:seal, token)) 61 | unsealed_token = account_provider.unseal(payload) 62 | expect(token.token_object).to eq(unsealed_token.token_object) 63 | expect("hai").to eq(unsealed_token.decode) 64 | end 65 | 66 | it "tokens can be sealed and unsealed when there are multiple unseal keys when ordered in reverse" do 67 | # Switch up the order just to make sure it works regardless of which comes first. 68 | expect(account_provider).to receive(:unseal_keys).and_return( 69 | [unused_unseal_key, account_provider.unseal_keys[0]] 70 | ).at_least(:once) 71 | 72 | payload = Base64.strict_decode64(account_provider.send(:seal, token)) 73 | unsealed_token = account_provider.unseal(payload) 74 | expect(token.token_object).to eq(unsealed_token.token_object) 75 | expect("hai").to eq(unsealed_token.decode) 76 | end 77 | 78 | it "having no valid unseal keys raises errors" do 79 | expect(account_provider).to receive(:unseal_keys).and_return( 80 | [unused_unseal_key] 81 | ) 82 | payload = Base64.strict_decode64(account_provider.send(:seal, token)) 83 | expect { account_provider.unseal(payload) }.to raise_error(CryptoError) 84 | end 85 | 86 | it "invalid signatures raise errors" do 87 | # mess with the signature 88 | payload = Base64.strict_decode64(account_provider.send(:seal, token)) 89 | payload[-1] = payload[-1].next 90 | 91 | expect { account_provider.unseal(payload) }.to raise_error(CryptoError) 92 | end 93 | 94 | it "invalid sealed tokens errors" do 95 | # mess with the token 96 | payload = Base64.strict_decode64(account_provider.send(:seal, token)) 97 | payload = payload[1..-1] 98 | 99 | expect { account_provider.unseal(payload) }.to raise_error(RecoveryTokenSerializationError) 100 | end 101 | 102 | it "validates countersigned tokens" do 103 | sealed_token = Base64.strict_decode64(account_provider.send(:seal, token)) 104 | countersigned_token = recovery_provider.countersign_token(token: sealed_token) 105 | expect(account_provider.validate_countersigned_recovery_token!(countersigned_token)).to_not be_nil 106 | end 107 | 108 | it "rejects countersigned tokens where the nested token has an invalid type" do 109 | token.token_object.version = 99999999 110 | countersigned_token = recovery_provider.send(:seal, raw_token(recovery_token: token)) 111 | expect { 112 | account_provider.validate_countersigned_recovery_token!(countersigned_token) 113 | }.to raise_error(CountersignedTokenError, /Nested recovery token format error: Version field must be 0/) 114 | end 115 | 116 | it "rejects countersigned tokens with an invalid version number" do 117 | countersigned_token = recovery_provider.send(:seal, raw_token(recovery_token: token, version: 99999999)) 118 | expect { 119 | account_provider.validate_countersigned_recovery_token!(countersigned_token) 120 | }.to raise_error(CountersignedTokenError, /Version field must be 0/) 121 | end 122 | 123 | it "rejects countersigned tokens with the unknown issuers" do 124 | token.token_object.audience = "https://fooooobarrrr.com" 125 | countersigned_token = recovery_provider.send(:seal, raw_token(recovery_token: token)) 126 | expect { 127 | account_provider.validate_countersigned_recovery_token!(countersigned_token) 128 | }.to raise_error(CountersignedTokenError, /Validate the the issuer field is present in the countersigned-token, and that it matches the audience field in the original token/) 129 | end 130 | 131 | it "rejects countersigned tokens when the recovery token issuer is not the same as the countersigned token audience" do 132 | countersigned_token = recovery_provider.send(:seal, raw_token(recovery_token: token, issuer: "https://fooooooo.com")) 133 | expect { 134 | account_provider.validate_countersigned_recovery_token!(countersigned_token) 135 | }.to raise_error(CountersignedTokenError, /Unknown recovery provider/) 136 | end 137 | 138 | 139 | it "rejects stale countersigned tokens" do 140 | countersigned_token = recovery_provider.send(:seal, raw_token(recovery_token: token, issued_time: (Time.new - CLOCK_SKEW - 1).iso8601)) 141 | expect { 142 | account_provider.validate_countersigned_recovery_token!(countersigned_token) 143 | }.to raise_error(CountersignedTokenError, /Countersigned recovery token issued at time is too far in the past/) 144 | 145 | countersigned_token = recovery_provider.send(:seal, raw_token(recovery_token: token, issued_time: "")) 146 | expect { 147 | account_provider.validate_countersigned_recovery_token!(countersigned_token) 148 | }.to raise_error(CountersignedTokenError, /Invalid countersigned token issued time/) 149 | 150 | countersigned_token = recovery_provider.send(:seal, raw_token(recovery_token: token, issued_time: "steve")) 151 | expect { 152 | account_provider.validate_countersigned_recovery_token!(countersigned_token) 153 | }.to raise_error(CountersignedTokenError, /Invalid countersigned token issued time/) 154 | end 155 | 156 | it "rejects countersigned tokens with an invalid signature" do 157 | countersigned_token = Base64.strict_decode64(recovery_provider.send(:seal, raw_token(recovery_token: token))) 158 | countersigned_token[-1] = countersigned_token[-1].next 159 | countersigned_token = Base64.strict_encode64(countersigned_token) 160 | 161 | expect { 162 | account_provider.validate_countersigned_recovery_token!(countersigned_token) 163 | }.to raise_error(CountersignedTokenError, /Countersigned token has an invalid signature/) 164 | end 165 | 166 | it "rejects invalid countersigned tokens" do 167 | countersigned_token = recovery_provider.send(:seal, raw_token(recovery_token: token)) 168 | decoded = Base64.strict_decode64(countersigned_token) 169 | countersigned_token = Base64.strict_encode64(decoded[5..-1]) 170 | 171 | expect { 172 | account_provider.validate_countersigned_recovery_token!(countersigned_token) 173 | }.to raise_error(CountersignedTokenError, /Countersigned token is invalid: data truncated/) 174 | end 175 | 176 | it "rejects contersigned tokens where the nested token's signature is invalid" do 177 | payload = Base64.strict_decode64(account_provider.send(:seal, token)) 178 | payload[-1] = payload[-1].next 179 | countersigned_token = recovery_provider.send(:seal, raw_token(data: payload)) 180 | 181 | expect { 182 | account_provider.validate_countersigned_recovery_token!(countersigned_token) 183 | }.to raise_error(CountersignedTokenError, /Nested recovery token has an invalid signature/) 184 | end 185 | 186 | it "rejects contersigned tokens where the nested token is invalid" do 187 | payload = Base64.strict_decode64(account_provider.send(:seal, token)) 188 | payload = payload[1..-1].next 189 | countersigned_token = recovery_provider.send(:seal, raw_token(data: payload)) 190 | 191 | expect { 192 | account_provider.validate_countersigned_recovery_token!(countersigned_token) 193 | }.to raise_error(CountersignedTokenError, /Nested recovery token is invalid: /) 194 | end 195 | 196 | it "rejects contersigned tokens where the nested token can't be decrypted" do 197 | garbage_data = EncryptedData.build("foo").to_binary_s 198 | garbage_data[-1] = garbage_data[-1].next 199 | token.data = garbage_data 200 | payload = Base64.strict_decode64(account_provider.send(:seal, token)) 201 | countersigned_token = recovery_provider.seal(raw_token(data: payload)) 202 | 203 | validated_token = account_provider.validate_countersigned_recovery_token!(countersigned_token) 204 | expect { 205 | validated_token.decode 206 | }.to raise_error(CryptoError, /Unable to decrypt data/) 207 | end 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /spec/lib/darrrr/cryptors/default/encrypted_data_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../../../../spec_helper" 4 | 5 | module Darrrr 6 | describe EncryptedData do 7 | let(:data) { EncryptedData.build("hai") } 8 | 9 | it "can generate and parse encrypted data" do 10 | parsed_token = EncryptedData.parse(data.to_binary_s) 11 | expect(data.decrypt).to eq(parsed_token.decrypt) 12 | expect("hai").to eq(parsed_token.decrypt) 13 | end 14 | 15 | it "raises errors on version mismatches version mismatch" do 16 | data.token_object.version = 100 17 | expect { 18 | EncryptedData.parse(data.to_binary_s) 19 | }.to raise_error(TokenFormatError) 20 | end 21 | 22 | it "rejects tokens with an invalid auth_tag" do 23 | data.token_object.auth_tag = SecureRandom.random_bytes(EncryptedData::AUTH_TAG_LENGTH).bytes 24 | expect { 25 | EncryptedData.parse(data.to_binary_s).decrypt 26 | }.to raise_error(CryptoError) 27 | end 28 | 29 | it "raises an error when a token has bogus crypto primitives" do 30 | data.token_object.iv = SecureRandom.random_bytes(EncryptedData::IV_LENGTH).bytes 31 | expect { 32 | EncryptedData.parse(data.to_binary_s).decrypt 33 | }.to raise_error(CryptoError) 34 | end 35 | 36 | it "raises an error when data can't be decrypted" do 37 | data.token_object.ciphertext = ("garbage" + data.ciphertext.to_binary_s).bytes 38 | expect { 39 | EncryptedData.parse(data.to_binary_s).decrypt 40 | }.to raise_error(CryptoError) 41 | end 42 | 43 | it "truncated tokens raise crypto errors" do 44 | expect { 45 | EncryptedData.parse(data.to_binary_s[0..-3]).decrypt 46 | }.to raise_error(CryptoError) 47 | end 48 | 49 | it "invalid tokens raise parse errors" do 50 | expect { 51 | EncryptedData.parse("foooooo").decrypt 52 | }.to raise_error(RecoveryTokenSerializationError) 53 | end 54 | 55 | it "extra data at the end will raise an error" do 56 | expect { 57 | EncryptedData.parse(data.to_binary_s + "lululu").decrypt 58 | }.to raise_error(CryptoError) 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/lib/darrrr/recovery_provider_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../../spec_helper" 4 | 5 | module Darrrr 6 | describe RecoveryProvider, vcr: { cassette_name: "delegated_account_recovery/recovery_provider" } do 7 | include DelegatedRecoveryHelpers 8 | 9 | let(:recovery_provider) { example_recovery_provider } 10 | let(:account_provider) { AccountProvider.this } 11 | let(:token) { account_provider.generate_recovery_token(data: "data", audience: recovery_provider).first } 12 | 13 | let(:raw_token) do 14 | RecoveryTokenWriter.new.tap do |token| 15 | token.token_id = SecureRandom.random_bytes(16).bytes.to_a 16 | token.issuer = account_provider.issuer 17 | token.issued_time = Time.now.utc.iso8601 18 | token.options = 0 # when the token-status endpoint is implemented, change this to 1 19 | token.audience = recovery_provider.issuer 20 | token.binding_data = "foo" 21 | token.token_type = RECOVERY_TOKEN_TYPE 22 | token.version = PROTOCOL_VERSION 23 | token.data = EncryptedData.build("data").to_binary_s 24 | end 25 | end 26 | 27 | # Generate a random key that isn't used during the sealing process. 28 | # openssl ecparam -name prime256v1 -genkey -noout -out prime256v1-key.pem 29 | # openssl ec -in prime256v1-key.pem -pubout -out prime256v1-pub.pem 30 | # cat prime256v1-pub.pem 31 | let(:unused_unseal_key) { "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAElAcL7Ki9+1QaGjZE6CLmfbEVqWjGYIm90rDp/Qy+kRCUyW6l5XSuffQyMWwq8rRMONmNzUk4rDgJr1hepp0y5w==" } 32 | 33 | let(:config) do 34 | { 35 | "issuer" => "https://example-provider.org", 36 | "countersign-pubkeys-secp256r1" => [ 37 | "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEo6KcKtGsBuRHbwtg6xOQlmd5ECwgcBsxeNGpZzEnIS+SUFQnSARacWOgU7rfezqu4lVxuXa55rYvtyYZ8TCRqg==" 38 | ], 39 | "token-max-size" => 8192, 40 | "save-token" => "https://example-provider.org/recovery/delegated/save", 41 | "recover-account" => "https://example-provider.org/recovery/delegated/recover", 42 | "save-token-async-api-iframe" => "https://example-provider.org/plugins/delegated_account_recovery", 43 | "privacy-policy" => "https://example-provider.org/about/privacy/" 44 | } 45 | end 46 | 47 | it "provides extra options for faraday" do 48 | Darrrr.faraday_config_callback = lambda do |faraday| 49 | faraday.headers["Accept-Encoding"] = "foo" 50 | faraday.adapter(Faraday.default_adapter) 51 | end 52 | recovery_provider = example_recovery_provider 53 | 54 | # assert extra header 55 | recovery_provider_faraday = recovery_provider.send(:faraday) 56 | expect(recovery_provider_faraday.headers).to include("Accept-Encoding" => "foo") 57 | 58 | # assert handler is property set 59 | handler = JSON.parse(recovery_provider_faraday.to_json)["builder"]["adapter"] 60 | expect(handler).to_not be_nil 61 | handler_name = handler["name"] 62 | expect(handler_name).to_not be_nil 63 | expect(handler_name).to eq("Faraday::Adapter::NetHttp") 64 | end 65 | 66 | it "does not accept in incomplete config" do 67 | config.delete("issuer") 68 | expect { 69 | RecoveryProvider.new("https://example-provider.org", attrs: config) 70 | }.to raise_error(ProviderConfigError) 71 | end 72 | 73 | it "does not accept a config missing a public key" do 74 | config.delete("countersign-pubkeys-secp256r1") 75 | expect { 76 | RecoveryProvider.new("https://example-provider.org", attrs: config) 77 | }.to raise_error(ProviderConfigError) 78 | end 79 | 80 | it "reports 404 errors when retrieving configs" do 81 | expect { 82 | RecoveryProvider.new("https://www.faceboooooook.com").load 83 | }.to raise_error(ProviderConfigError) 84 | end 85 | 86 | it "reports JSON parse errors when retrieving configs" do 87 | expect { 88 | RecoveryProvider.new("https://bad-json.com").load 89 | }.to raise_error(ProviderConfigError) 90 | end 91 | 92 | it "does not accept configs with invalid URLs" do 93 | config["save-token"] = "totally not a URL!!111 :)" 94 | expect { 95 | RecoveryProvider.new("https://example-provider.org", attrs: config) 96 | }.to raise_error(ProviderConfigError) 97 | end 98 | 99 | it "can verify recovery tokens" do 100 | sealed_token = account_provider.send(:seal, token) 101 | expect(recovery_provider.validate_recovery_token!(Base64.strict_decode64(sealed_token))).to_not be_nil 102 | end 103 | 104 | it "rejects tokens with invalid signatures" do 105 | sealed_token = account_provider.send(:seal, token)[0..-5] 106 | expect { 107 | recovery_provider.validate_recovery_token!(Base64.strict_decode64(sealed_token)) 108 | }.to raise_error(RecoveryTokenError, /Unable to verify signature of token/) 109 | end 110 | 111 | it "rejects invalid tokens" do 112 | sealed_token = account_provider.send(:seal, token)[5..0] 113 | expect { 114 | recovery_provider.validate_recovery_token!(Base64.strict_decode64(sealed_token)) 115 | }.to raise_error(RecoveryTokenError, /Could not determine provider/) 116 | end 117 | 118 | it "rejects tokens with invalid version numbers" do 119 | raw_token.version = 9999999 120 | 121 | sealed_token = account_provider.send(:seal, RecoveryToken.parse(raw_token.to_binary_s)) 122 | expect { 123 | recovery_provider.validate_recovery_token!(Base64.strict_decode64(sealed_token)) 124 | }.to raise_error(RecoveryTokenError, /Version field must be 0/) 125 | end 126 | 127 | it "rejects tokens with an 'old' issued at date" do 128 | raw_token.issued_time = (Time.new - CLOCK_SKEW - 1).iso8601 129 | sealed_token = account_provider.send(:seal, RecoveryToken.parse(raw_token.to_binary_s)) 130 | expect { 131 | recovery_provider.validate_recovery_token!(Base64.strict_decode64(sealed_token)) 132 | }.to raise_error(RecoveryTokenError, /Issued at time is too far in the past/) 133 | end 134 | 135 | it "rejects tokens with an invalid issuer" do 136 | raw_token.audience = "foo.bar" 137 | sealed_token = account_provider.send(:seal, RecoveryToken.parse(raw_token.to_binary_s)) 138 | expect { 139 | recovery_provider.validate_recovery_token!(Base64.strict_decode64(sealed_token)) 140 | }.to raise_error(RecoveryTokenError, /Unnacceptable audience/) 141 | end 142 | 143 | it "rejects tokens with an invalid token type" do 144 | raw_token.token_type = 999999999 145 | sealed_token = account_provider.send(:seal, RecoveryToken.parse(raw_token.to_binary_s)) 146 | expect { 147 | recovery_provider.validate_recovery_token!(Base64.strict_decode64(sealed_token)) 148 | }.to raise_error(RecoveryTokenError, /Token type must be 0/) 149 | end 150 | 151 | it "countersigns tokens" do 152 | sealed_token = Base64.strict_decode64(account_provider.send(:seal, token)) 153 | countersigned_token = recovery_provider.countersign_token(token: sealed_token) 154 | raw_counter_token = recovery_provider.unseal(Base64.strict_decode64(countersigned_token)) 155 | expect(sealed_token).to eq(raw_counter_token.data.to_binary_s) 156 | expect(account_provider.unseal(raw_counter_token.data.to_binary_s)).to_not be_nil 157 | end 158 | 159 | it "sets the option value when countersigning tokens" do 160 | sealed_token = Base64.strict_decode64(account_provider.send(:seal, token)) 161 | countersigned_token = recovery_provider.countersign_token(token: sealed_token, options: 0x02) 162 | raw_counter_token = recovery_provider.unseal(Base64.strict_decode64(countersigned_token)) 163 | expect(raw_counter_token.options).to eq(0x02) 164 | end 165 | 166 | it "countersigned tokens can be unsealed when there are multiple unseal keys" do 167 | expect(recovery_provider).to receive(:unseal_keys).and_return( 168 | [recovery_provider.unseal_keys[0], unused_unseal_key] 169 | ) 170 | sealed_token = Base64.strict_decode64(account_provider.send(:seal, token)) 171 | countersigned_token = recovery_provider.countersign_token(token: sealed_token) 172 | raw_counter_token = recovery_provider.unseal(Base64.strict_decode64(countersigned_token)) 173 | expect(sealed_token).to eq(raw_counter_token.data.to_binary_s) 174 | expect(account_provider.unseal(sealed_token)).to_not be_nil 175 | end 176 | 177 | it "countersigned tokens can be unsealed when there are multiple unseal keys when the order is swapped" do 178 | # Switch up the order just to make sure it works regardless of which comes first. 179 | expect(recovery_provider).to receive(:unseal_keys).and_return( 180 | [unused_unseal_key, recovery_provider.unseal_keys[0]] 181 | ) 182 | sealed_token = Base64.strict_decode64(account_provider.send(:seal, token)) 183 | countersigned_token = recovery_provider.countersign_token(token: sealed_token) 184 | raw_counter_token = recovery_provider.unseal(Base64.strict_decode64(countersigned_token)) 185 | expect(sealed_token).to eq(raw_counter_token.data.to_binary_s) 186 | expect(account_provider.unseal(sealed_token)).to_not be_nil 187 | end 188 | 189 | it "doesn't countersign tokens it can't parse" do 190 | sealed_token = Base64.strict_decode64(account_provider.send(:seal, token)).reverse 191 | expect { 192 | recovery_provider.countersign_token(token: sealed_token) 193 | }.to raise_error(TokenFormatError) 194 | end 195 | 196 | it "having no valid countersign unseal keys raises errors" do 197 | expect(recovery_provider).to receive(:unseal_keys).and_return( 198 | [unused_unseal_key] 199 | ) 200 | sealed_token = Base64.strict_decode64(account_provider.send(:seal, token)) 201 | countersigned_token = recovery_provider.countersign_token(token: sealed_token) 202 | expect { 203 | recovery_provider.unseal(Base64.strict_decode64(countersigned_token)) 204 | }.to raise_error(CryptoError) 205 | end 206 | end 207 | end 208 | -------------------------------------------------------------------------------- /spec/lib/darrrr/recovery_token_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../../spec_helper" 4 | 5 | module Darrrr 6 | describe RecoveryToken, vcr: { cassette_name: "delegated_account_recovery/recovery_provider" } do 7 | let(:binding) { SecureRandom.hex } 8 | let(:recovery_provider) { example_account_provider } 9 | let(:token) { AccountProvider.this.generate_recovery_token(data: "hai", audience: recovery_provider).first } 10 | 11 | it "can generate and parse a token" do 12 | parsed_token = RecoveryToken.parse(token.to_binary_s) 13 | expect(parsed_token.audience).to eq(recovery_provider.issuer) 14 | end 15 | 16 | it "token data can be decrypted" do 17 | parsed_token = RecoveryToken.parse(token.to_binary_s) 18 | data = parsed_token.decode 19 | 20 | expect(data).to_not be_nil 21 | expect("hai").to eq(data) 22 | end 23 | 24 | it "truncated tokens raise parse errors" do 25 | expect { 26 | RecoveryToken.parse(token.to_binary_s[0..-3]) 27 | }.to raise_error(RecoveryTokenSerializationError) 28 | end 29 | 30 | it "invalid tokens raise parse errors" do 31 | expect { 32 | RecoveryToken.parse(token.to_binary_s.reverse) 33 | }.to raise_error(RecoveryTokenSerializationError) 34 | end 35 | 36 | it "extra data at the end of a token (e.g. a signature) does not cause errors" do 37 | RecoveryToken.parse(token.to_binary_s + "lululu") # assert_doesnt_raise_error 38 | end 39 | 40 | it "returns binary encoded data despite Encoding.default_internal" do 41 | w, $_w = $_w, false 42 | before_enc = Encoding.default_internal 43 | 44 | begin 45 | Encoding.default_internal = Encoding::UTF_8 46 | AccountProvider.this.generate_recovery_token(data: "hai", audience: recovery_provider).first 47 | ensure 48 | Encoding.default_internal = before_enc 49 | $_w = w 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/lib/darrrr_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../spec_helper" 4 | 5 | describe Darrrr, vcr: { cassette_name: "delegated_account_recovery/recovery_provider", match_requests_on: [:method, :uri] } do 6 | context "#recovery_provider" do 7 | it "raises an error if you ask for an unregistered recovery provider" do 8 | expect { 9 | Darrrr.recovery_provider("https://not-registered.com") 10 | }.to raise_error(Darrrr::UnknownProviderError) 11 | end 12 | 13 | it "returns a registered recovery provider" do 14 | expect(Darrrr.recovery_provider("https://example-provider.org")).to be_kind_of(Darrrr::RecoveryProvider) 15 | end 16 | 17 | it "allows you to configure the recovery provider during retrieval" do 18 | Darrrr.faraday_config_callback = lambda do |faraday| 19 | faraday.adapter(Faraday.default_adapter) 20 | faraday.headers["Accept-Encoding"] = "foo" 21 | end 22 | recovery_provider = Darrrr.recovery_provider("https://example-provider.org") 23 | expect(recovery_provider).to be_kind_of(Darrrr::RecoveryProvider) 24 | 25 | # assert extra header 26 | recovery_provider_faraday = recovery_provider.send(:faraday) 27 | expect(recovery_provider_faraday.headers).to include("Accept-Encoding" => "foo") 28 | end 29 | end 30 | 31 | context "#register_recovery_provider" do 32 | it "allows registering domains" do 33 | begin 34 | before = Darrrr.recovery_providers 35 | 36 | new_provider = "https://www.new-provider.com" 37 | expect { 38 | Darrrr.recovery_provider(new_provider) 39 | }.to raise_error(Darrrr::UnknownProviderError) 40 | 41 | Darrrr.register_recovery_provider(new_provider) 42 | expect(Darrrr.recovery_provider(new_provider)).to be_kind_of(Darrrr::RecoveryProvider) 43 | ensure 44 | Darrrr.instance_variable_set(:@recovery_providers, before) 45 | end 46 | end 47 | end 48 | 49 | context "#account_provider" do 50 | it "raises an error if you ask for an unregistered account provider" do 51 | expect { 52 | Darrrr.account_provider("https://not-registered.com") 53 | }.to raise_error(Darrrr::UnknownProviderError) 54 | end 55 | 56 | it "returns a registered account provider" do 57 | expect(Darrrr.account_provider("https://example-provider.org")).to be_kind_of(Darrrr::AccountProvider) 58 | end 59 | end 60 | 61 | it "allows procs as values for tokensign_pubkeys_secp256r1" do 62 | expect(Darrrr.this_account_provider.instance_variable_get(:@tokensign_pubkeys_secp256r1)).to be_a(Proc) 63 | expect(Darrrr.this_account_provider.unseal_keys).to eq([ENV["ACCOUNT_PROVIDER_PUBLIC_KEY"]]) 64 | end 65 | 66 | it "allows procs as values for countersign_pubkeys_secp256r1" do 67 | expect(Darrrr.this_recovery_provider.instance_variable_get(:@countersign_pubkeys_secp256r1)).to be_a(Proc) 68 | expect(Darrrr.this_recovery_provider.unseal_keys).to eq([ENV["RECOVERY_PROVIDER_PUBLIC_KEY"]]) 69 | end 70 | 71 | it "passes context from high level operations to low level crypto calls when creating a token " do 72 | context = { foo: :bar } 73 | expect(Darrrr.this_account_provider.encryptor).to receive(:encrypt).with(anything, Darrrr.this_account_provider, context).and_return("crypted") 74 | expect(Darrrr.this_account_provider.encryptor).to receive(:sign).with(anything, anything, Darrrr.this_account_provider, context).and_return("signed") 75 | Darrrr.this_account_provider.generate_recovery_token(data: "plaintext", audience: Darrrr.this_recovery_provider, context: context) 76 | end 77 | 78 | it "passes context from high level operations to low level crypto calls when verifying/countersigning a token" do 79 | context = { foo: :bar } 80 | 81 | token, sealed_token = Darrrr.this_account_provider.generate_recovery_token(data: "foo", audience: Darrrr.this_recovery_provider) 82 | sealed_token = Base64.strict_decode64(sealed_token) 83 | 84 | expect(Darrrr.this_account_provider).to receive(:unseal_keys).with(context).and_return(["bar"]) 85 | 86 | expect(Darrrr.this_account_provider.encryptor).to receive(:verify).with(anything, anything, anything, anything, context).and_return(true) 87 | Darrrr.this_recovery_provider.validate_recovery_token!(sealed_token, context) 88 | 89 | expect(Darrrr.this_recovery_provider.encryptor).to receive(:sign).with(anything, anything, anything, context).and_return("signed") 90 | Darrrr.this_recovery_provider.countersign_token(token: sealed_token, context: context) 91 | end 92 | 93 | it "passes context from high level operations to low level crypto calls when verifying/countersigning a token" do 94 | context = { foo: :bar } 95 | token, sealed_token = Darrrr.this_account_provider.generate_recovery_token(data: "foo", audience: Darrrr.this_recovery_provider) 96 | sealed_token = Base64.strict_decode64(sealed_token) 97 | countersigned_token = Darrrr.this_recovery_provider.countersign_token(token: sealed_token, context: context) 98 | 99 | expect(Darrrr.this_account_provider).to receive(:unseal_keys).with(context).and_return(["bar"]) 100 | expect(Darrrr.this_account_provider.encryptor).to receive(:verify).with(anything, anything, anything, anything, context).and_return(true) 101 | expect(Darrrr.this_recovery_provider.encryptor).to receive(:verify).with(anything, anything, anything, anything, context).and_return(true) 102 | Darrrr.this_account_provider.validate_countersigned_recovery_token!(countersigned_token, context) 103 | end 104 | 105 | it "allows you to set the options value for a token" do 106 | token, _ = Darrrr.this_account_provider.generate_recovery_token(data: "foo", audience: Darrrr.this_recovery_provider, options: 0x02) 107 | expect(token.options).to eq(0x02) 108 | end 109 | 110 | context "#account_provider_config" do 111 | it "returns a hash" do 112 | expect(Darrrr.account_provider_config).to be_kind_of(Hash) 113 | end 114 | end 115 | 116 | context "#recovery_provider_config" do 117 | it "returns a hash" do 118 | expect(Darrrr.recovery_provider_config).to be_kind_of(Hash) 119 | end 120 | end 121 | 122 | context "#account_and_recovery_provider_config" do 123 | it "returns a hash" do 124 | expect(Darrrr.account_and_recovery_provider_config).to be_kind_of(Hash) 125 | end 126 | end 127 | 128 | context "#custom_encryptor=" do 129 | module BadEncryptor 130 | 131 | end 132 | 133 | module Rot13Encryptor 134 | class << self 135 | # credit: https://gist.github.com/rwoeber/274126 136 | def rot13(string) 137 | string.tr("A-Za-z", "N-ZA-Mn-za-m") 138 | end 139 | 140 | def sign(serialized_token, key, provider, context) 141 | "abc123" 142 | end 143 | 144 | def verify(payload, signature, key, provider, context) 145 | signature == "abc123" 146 | end 147 | 148 | def decrypt(encrypted_data, provider, context) 149 | rot13(encrypted_data) 150 | end 151 | 152 | def encrypt(data, provider, context) 153 | rot13(data) 154 | end 155 | end 156 | end 157 | 158 | let(:recovery_provider) { Darrrr.this_recovery_provider } 159 | let(:account_provider) { Darrrr.this_account_provider } 160 | 161 | it "rejects classes that don't define all operations" do 162 | expect { 163 | account_provider.custom_encryptor = BadEncryptor 164 | }.to raise_error(ArgumentError) 165 | end 166 | 167 | it "accepts classes that define all operations" do 168 | begin 169 | account_provider.custom_encryptor = Rot13Encryptor 170 | recovery_provider.custom_encryptor = Rot13Encryptor 171 | 172 | token, sealed_token = account_provider.generate_recovery_token(data: "foo", audience: recovery_provider) 173 | sealed_token = Base64.strict_decode64(sealed_token) 174 | recovery_provider.validate_recovery_token!(sealed_token) 175 | 176 | countersigned_token = recovery_provider.countersign_token(token: sealed_token) 177 | account_provider.validate_countersigned_recovery_token!(countersigned_token) 178 | 179 | unsealed_countersigned_token = recovery_provider.unseal(Base64.strict_decode64(countersigned_token)) 180 | recovery_token = account_provider.unseal(unsealed_countersigned_token.data.to_binary_s) 181 | expect(recovery_token.decode).to eq("foo") 182 | ensure 183 | account_provider.instance_variable_set(:@encryptor, nil) 184 | recovery_provider.instance_variable_set(:@encryptor, nil) 185 | end 186 | end 187 | 188 | it "allows you to specify a temporary using a block" do 189 | expect(account_provider.encryptor).to be(Darrrr::DefaultEncryptor) 190 | 191 | account_provider.with_encryptor(Rot13Encryptor) do 192 | recovery_provider.with_encryptor(Rot13Encryptor) do 193 | expect(account_provider.encryptor).to be(Rot13Encryptor) 194 | expect(recovery_provider.encryptor).to be(Rot13Encryptor) 195 | token, sealed_token = account_provider.generate_recovery_token(data: "foo", audience: recovery_provider) 196 | sealed_token = Base64.strict_decode64(sealed_token) 197 | recovery_provider.validate_recovery_token!(sealed_token) 198 | 199 | countersigned_token = recovery_provider.countersign_token(token: sealed_token) 200 | account_provider.validate_countersigned_recovery_token!(countersigned_token) 201 | 202 | unsealed_countersigned_token = recovery_provider.unseal(Base64.strict_decode64(countersigned_token)) 203 | recovery_token = account_provider.unseal(unsealed_countersigned_token.data.to_binary_s) 204 | expect(recovery_token.decode).to eq("foo") 205 | end 206 | end 207 | 208 | expect(account_provider.encryptor).to be(Darrrr::DefaultEncryptor) 209 | expect(recovery_provider.encryptor).to be(Darrrr::DefaultEncryptor) 210 | end 211 | end 212 | end 213 | -------------------------------------------------------------------------------- /spec/lib/integration/account_provider_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../../spec_helper" 4 | 5 | describe "AccountProviderController", vcr: { cassette_name: "delegated_account_recovery/integration_test" } do 6 | include Rack::Test::Methods 7 | 8 | def app 9 | Rack::Builder.parse_file("config.ru").first 10 | end 11 | 12 | it "creates a token" do 13 | post "/account-provider/create", recovery_provider: example_recovery_provider.origin, phrase: "foo" 14 | expect(last_response.status).to eq(200) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/lib/integration/integration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../../spec_helper" 4 | 5 | describe "Integration test", vcr: { cassette_name: "delegated_account_recovery/integration_test" } do 6 | it "serves up the config" do 7 | visit "/.well-known/delegated-account-recovery/configuration" 8 | response = JSON.parse(body) 9 | expect(response).to include(Darrrr::AccountProvider.this.to_h) 10 | expect(response).to include(Darrrr::RecoveryProvider.this.to_h) 11 | end 12 | 13 | it "can store and recover a token" do 14 | visit "/account-provider" 15 | secret = find_field(:phrase).value 16 | page.click_on("connect to http://localhost:9292") 17 | page.click_on("Setup recovery") 18 | page.click_on("Recover now?") 19 | page.click_on("Recover token") 20 | 21 | assert_text("Recovered data: #{secret}") 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/lib/integration/recovery_provider_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../../spec_helper" 4 | 5 | describe "AccountProviderController", vcr: { cassette_name: "delegated_account_recovery/integration_test" } do 6 | include Rack::Test::Methods 7 | 8 | def app 9 | Rack::Builder.parse_file("config.ru").first 10 | end 11 | 12 | it "saves a token" do 13 | token, sealed_token = example_account_provider.generate_recovery_token(data: "foo", audience: Darrrr::RecoveryProvider.this) 14 | post "/recovery-provider/save-token", token: sealed_token 15 | expect(last_response.header["location"]).to match("save-success") 16 | end 17 | 18 | it "rejects a bad token" do 19 | token, sealed_token = example_account_provider.generate_recovery_token(data: "foo", audience: Darrrr::RecoveryProvider.this) 20 | sealed_token = sealed_token[0..-5] 21 | post "/recovery-provider/save-token", token: sealed_token 22 | expect(last_response.header["location"]).to match("save-failure") 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "pry" 5 | require "vcr" 6 | require "webmock" 7 | require "json" 8 | require "watir" 9 | require "capybara" 10 | require "capybara/dsl" 11 | require "capybara/poltergeist" 12 | require "sinatra" 13 | require "securerandom" 14 | require "database_cleaner" 15 | 16 | ENV["ACCOUNT_PROVIDER_PUBLIC_KEY"] = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEks3CjRTWrTnEDEiz36ICsy3mOX7fhauJ3Jj3R6hN7rp0Q6zh3WKIhGMBR8Ccc1VKZ4eMqLmw/WQLHSAn22GD4g==" 17 | ENV["ACCOUNT_PROVIDER_PRIVATE_KEY"] = "MHcCAQEEIKrHDRd0Bn3PkY9fU4AaDErNIKPkMCdL9tGNvwyWXdPqoAoGCCqGSM49AwEHoUQDQgAEks3CjRTWrTnEDEiz36ICsy3mOX7fhauJ3Jj3R6hN7rp0Q6zh3WKIhGMBR8Ccc1VKZ4eMqLmw/WQLHSAn22GD4g==" 18 | ENV["TOKEN_DATA_AES_KEY"] = "8d8aecbf68e51d72f1b95443e308db238c8984ec3fc4bf876e1a63643d211559" 19 | ENV["RECOVERY_PROVIDER_PRIVATE_KEY"] = "MHcCAQEEIJ4GmCrFP2vxpNCyFo+XOicLVzplFpUkvvp0yWnuNK7hoAoGCCqGSM49AwEHoUQDQgAEcUoYO9viRDXApcOgjVWlA2e4GTwJV4DzysupSswayKGhZsZMeL2Tlsc4fKkTTyfdRWZ4C1ShO1XQWiowaa1q8w==" 20 | ENV["RECOVERY_PROVIDER_PUBLIC_KEY"] = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEcUoYO9viRDXApcOgjVWlA2e4GTwJV4DzysupSswayKGhZsZMeL2Tlsc4fKkTTyfdRWZ4C1ShO1XQWiowaa1q8w==" 21 | ENV["COOKIE_SECRET"] = SecureRandom.hex 22 | ENV["RACK_ENV"] = "test" 23 | 24 | require "simplecov" 25 | require "simplecov-json" 26 | SimpleCov.formatters = [ 27 | SimpleCov::Formatter::JSONFormatter, 28 | SimpleCov::Formatter::HTMLFormatter, 29 | ] 30 | SimpleCov.start 31 | 32 | Sinatra::Application.environment = :test 33 | Capybara.javascript_driver = :poltergeist 34 | Capybara.app = Rack::Builder.parse_file("config.ru").first 35 | 36 | ActiveRecord::Base.logger = nil 37 | 38 | Darrrr.register_account_provider("https://example-provider.org") 39 | Darrrr.register_recovery_provider("https://example-provider.org") 40 | 41 | VCR.configure do |config| 42 | config.cassette_library_dir = "fixtures/vcr_cassettes" 43 | config.hook_into :webmock 44 | config.configure_rspec_metadata! 45 | config.default_cassette_options = { record: :none, allow_playback_repeats: true } 46 | config.allow_http_connections_when_no_cassette = true 47 | end 48 | 49 | module DelegatedRecoveryHelpers 50 | def example_recovery_provider 51 | Darrrr.recovery_provider("https://example-provider.org").tap do |provider| 52 | provider.signing_private_key = "MHcCAQEEIIgnR3rlLFoqr9aND4Zy+2BybCBvHjBbXbZVl22iYJzloAoGCCqGSM49AwEHoUQDQgAEt8Q2mx9vXutOdCPlPP0J9qrJs/7aULPCXNyWfwOvt6k9vb2DIVqD3f7HlYOjZTt1xyUVAicfXbiuPA7sp/iaBA==" 53 | end 54 | end 55 | 56 | def example_account_provider 57 | Darrrr.account_provider("https://example-provider.org").tap do |provider| 58 | provider.signing_private_key = "MHcCAQEEIEhIgVNH4w+vt9pMe71GE3WBxz5yyCJUHl9/72RHFqdZoAoGCCqGSM49AwEHoUQDQgAE+LQRJeAXDpYknpWVn4lEKq0Q1ydH8c7GRcSmOzyLUvOXAxdl11spiqxuw13mHknoTRW0EutMo2gn9ID+uB0WpQ==" 59 | end 60 | end 61 | end 62 | 63 | 64 | RSpec.configure do |config| 65 | config.include DelegatedRecoveryHelpers 66 | config.include Capybara::DSL 67 | 68 | config.before(:suite) do 69 | DatabaseCleaner.strategy = :transaction 70 | DatabaseCleaner.clean_with(:truncation) 71 | end 72 | 73 | config.before(:each) do 74 | Darrrr.faraday_config_callback = nil 75 | end 76 | 77 | config.around(:each) do |example| 78 | DatabaseCleaner.cleaning do 79 | example.run 80 | end 81 | end 82 | 83 | config.expect_with :rspec do |expectations| 84 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 85 | end 86 | 87 | config.mock_with :rspec do |mocks| 88 | mocks.verify_partial_doubles = true 89 | end 90 | 91 | config.shared_context_metadata_behavior = :apply_to_host_groups 92 | end 93 | -------------------------------------------------------------------------------- /views/index.erb: -------------------------------------------------------------------------------- 1 |
2 |

Setup Recovery

3 |
4 |
5 |
6 |

7 | To keep this example application simple, we won't actually create and manage accounts. 8 | In fact, this entire application is stateless on the server side. The phrase you see 9 | below is not saved anywhere unless you stash it at another account you control. We will 10 | encrypt the phrase so that it can't be viewed by that site; only this application can 11 | decrypt it again later. This phrase isn't part of the protocol, the data you store as 12 | part of your recovery token could be anything - most likely it would be a user id or 13 | a crypto key in a real application. 14 |

15 | 16 |

17 | Here is your random phrase: 18 |

19 |
20 | 21 |
22 | <%= Rack::Csrf.csrf_tag(env) %> 23 |
To save your phrase for recovery, choose a provider where you have an account:
24 | 25 |
26 | 27 | 28 | 29 |
30 |
31 |
32 | -------------------------------------------------------------------------------- /views/recover_account_return_post.erb: -------------------------------------------------------------------------------- 1 |

This is totally facebook.com

2 | 3 |

We've authenticated this request with 2fa and stuff. According to us, you are who you say you are.

4 | 5 |

Coutnersigned token:

6 |
 <%= token %> 
7 | 8 |

Would you like to try to prove to <%= recover_account_return_endpoint %> that you are who we say you are?

9 |
10 | 11 | 12 |
13 | -------------------------------------------------------------------------------- /views/recovered.erb: -------------------------------------------------------------------------------- 1 | Recovered data: <%= decrypted_data %> 2 | -------------------------------------------------------------------------------- /views/recovery_post.erb: -------------------------------------------------------------------------------- 1 |

Token: <%= Base64.encode64(token.to_binary_s) %>

2 |
 3 |   <%= token.token_object.to_s.split(", :").join("\n") %>
 4 | 
5 | 6 |
7 | <%= Rack::Csrf.csrf_tag(env) %> 8 | 9 | 10 | 11 |
12 | -------------------------------------------------------------------------------- /views/save_token_failure.erb: -------------------------------------------------------------------------------- 1 | Failed. 2 | -------------------------------------------------------------------------------- /views/save_token_success.erb: -------------------------------------------------------------------------------- 1 |
2 |

Recovery Setup Successful

3 |
4 |
5 |
6 |

7 | Congratulations, your token was saved. 8 |

9 |

10 | Recover now? 11 |

12 |
13 |
14 | --------------------------------------------------------------------------------