├── .gems ├── makefile ├── nobi.gemspec ├── LICENSE ├── README.md ├── tests └── nobi_test.rb └── lib └── nobi.rb /.gems: -------------------------------------------------------------------------------- 1 | cutest -v 1.2.0 2 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | test: 2 | cutest tests/*_test.rb 3 | -------------------------------------------------------------------------------- /nobi.gemspec: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | Gem::Specification.new do |s| 4 | s.name = "nobi" 5 | s.version = "0.0.1" 6 | s.summary = "Ruby port of itsdangerous python signer." 7 | s.description = "Ruby port of itsdangerous python signer." 8 | s.authors = ["Cyril David"] 9 | s.email = ["cyx@cyx.is"] 10 | s.homepage = "http://cyx.is" 11 | s.files = Dir[ 12 | "LICENSE", 13 | "README*", 14 | "makefile", 15 | "lib/**/*.rb", 16 | "*.gemspec", 17 | "tests/*.*", 18 | ] 19 | 20 | s.license = "MIT" 21 | 22 | s.add_development_dependency "cutest" 23 | end 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Cyril David 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | nobi 2 | ==== 3 | 4 | Requirements 5 | ------------ 6 | 7 | - Ruby 2.0 8 | 9 | 10 | Ruby port of [itsdangerous][itsdangerous] python signer. 11 | 12 | [itsdangerous]: http://pythonhosted.org/itsdangerous/ 13 | 14 | Examples 15 | -------- 16 | 17 | Best two usecases: 18 | 19 | 1. Creating an activation link for users 20 | 2. Creating a password reset link 21 | 22 | ## Creating an activation link for users 23 | 24 | ```ruby 25 | signer = Nobi::Signer.new('my secret') 26 | 27 | # Let's say the user's ID is 101 28 | signed = signer.sign('101') 29 | 30 | # You can now email this url to your users! 31 | url = "http://yoursite.com/activate/?key=%s" % signed 32 | ``` 33 | 34 | ## Creating a password reset link 35 | ```ruby 36 | signer = Nobi::TimestampSigner.new('my secret') 37 | 38 | # Let's say the user's ID is 101 39 | signed = signer.sign('101') 40 | 41 | # You can now email this url to your users! 42 | url = "http://yoursite.com/password-reset/?key=%s" % signed 43 | 44 | # In your code, you can verify the expiration: 45 | signer.unsign(signed, max_age: 86400) # 1 day expiration 46 | ``` 47 | 48 | ## Installation 49 | 50 | As usual, you can install it using rubygems. 51 | 52 | ``` 53 | $ gem install nobi 54 | ``` 55 | -------------------------------------------------------------------------------- /tests/nobi_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../lib/nobi', File.dirname(__FILE__)) 2 | require 'cutest' 3 | 4 | scope do 5 | setup do 6 | Nobi::Signer.new('foo') 7 | end 8 | 9 | test 'sign + unsign' do |s| 10 | assert_equal 'bar', s.unsign(s.sign('bar')) 11 | end 12 | 13 | test 'sign + unsign an int' do |s| 14 | assert_raise TypeError do 15 | s.sign(1) 16 | end 17 | end 18 | 19 | test 'no signature' do |s| 20 | assert_raise Nobi::BadSignature do 21 | s.unsign('nosep') 22 | end 23 | end 24 | 25 | test 'bad signature' do |s| 26 | assert_raise Nobi::BadSignature do 27 | s.unsign('bar.whatever') 28 | end 29 | end 30 | end 31 | 32 | scope do 33 | setup do 34 | Nobi::TimestampSigner.new('foo') 35 | end 36 | 37 | test 'sign + unsign' do |ts| 38 | assert_equal 'bar', ts.unsign(ts.sign('bar')) 39 | end 40 | 41 | test 'sign with no timestamp + unsign with timestamp' do |ts| 42 | s = Nobi::Signer.new('foo') 43 | 44 | assert_raise Nobi::BadTimeSignature do 45 | ts.unsign(s.sign('bar')) 46 | end 47 | end 48 | 49 | test 'unsign return_ timestamp' do |ts| 50 | time = Time.now.utc 51 | signed = ts.sign('bar') 52 | 53 | value, timestamp = ts.unsign(signed, return_timestamp: true) 54 | 55 | assert_equal 'bar', value 56 | 57 | # Because we can't make the exact time down to the fractional second, 58 | # we need to compare the time on an int level. 59 | assert_equal time.to_i, timestamp.to_i 60 | end 61 | 62 | test 'signature expired' do |ts| 63 | signed = ts.sign('bar') 64 | 65 | sleep 0.09 66 | 67 | assert_raise Nobi::SignatureExpired do 68 | ts.unsign(signed, max_age: 0.1) 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/nobi.rb: -------------------------------------------------------------------------------- 1 | require 'base64' 2 | require 'openssl' 3 | 4 | module Nobi 5 | BadData = Class.new(StandardError) 6 | BadSignature = Class.new(BadData) 7 | SignatureExpired = Class.new(BadData) 8 | BadTimeSignature = Class.new(BadSignature) 9 | 10 | module Utils 11 | def self.base64_encode(string) 12 | Base64.urlsafe_encode64(string).gsub(/=+$/, '') 13 | end 14 | 15 | def self.base64_decode(string) 16 | Base64.urlsafe_decode64(string + '=' * (-string.length % 4)) 17 | end 18 | 19 | def self.int_to_bytes(num) 20 | raise ArgumentError unless num >= 0 21 | 22 | rv = [] 23 | 24 | while num > 0 25 | rv << (num & 0xff).chr 26 | num >>= 8 27 | end 28 | 29 | return rv.reverse.join 30 | end 31 | 32 | def self.bytes_to_int(bytes) 33 | bytes.each_byte.inject(0) do |acc, byte| 34 | acc << 8 | byte 35 | end 36 | end 37 | 38 | def self.constant_time_compare(val1, val2) 39 | return false unless val1.length == val2.length 40 | 41 | cmp = val2.bytes.to_a 42 | result = 0 43 | 44 | val1.bytes.each_with_index do |char, index| 45 | result |= char ^ cmp[index] 46 | end 47 | 48 | return result == 0 49 | end 50 | 51 | def self.rsplit(str, sep) 52 | if str =~ /\A(.*)#{Regexp.escape(sep)}([^#{Regexp.escape(sep)}]+)\z/ 53 | return $1, $2 54 | end 55 | end 56 | end 57 | 58 | class HMACAlgorithm 59 | def initialize(digest_method) 60 | @digest_method = digest_method 61 | end 62 | 63 | def signature(key, value) 64 | OpenSSL::HMAC.digest(@digest_method, key, value) 65 | end 66 | end 67 | 68 | class Signer 69 | def initialize(secret, 70 | salt: 'nobi.Signer', 71 | sep: '.', 72 | digest_method: 'sha1') 73 | 74 | @secret = secret 75 | @salt = salt 76 | @sep = sep 77 | @algorithm = HMACAlgorithm.new(digest_method) 78 | end 79 | 80 | def sign(value) 81 | '%s%s%s' % [value, @sep, signature(value)] 82 | end 83 | 84 | def unsign(value) 85 | if not value.include?(@sep) 86 | raise BadSignature, 'No "%s" found in value' % @sep 87 | end 88 | 89 | value, sig = Utils.rsplit(value, @sep) 90 | 91 | if Utils.constant_time_compare(sig, signature(value)) 92 | return value 93 | end 94 | 95 | raise BadSignature, 'Signature "%s" does not match' % sig 96 | end 97 | 98 | def derive_key 99 | @algorithm.signature(@secret, @salt) 100 | end 101 | 102 | def signature(value) 103 | key = derive_key 104 | sig = @algorithm.signature(key, value) 105 | 106 | Utils.base64_encode(sig) 107 | end 108 | end 109 | 110 | class TimestampSigner < Signer 111 | # 2011/01/01 in UTC 112 | EPOCH = 1293840000 113 | 114 | def get_timestamp 115 | Time.now.utc.to_f - EPOCH 116 | end 117 | 118 | def timestamp_to_datetime(ts) 119 | Time.at(ts + EPOCH).utc 120 | end 121 | 122 | def sign(value) 123 | timestamp = Utils.base64_encode(Utils.int_to_bytes(get_timestamp.to_i)) 124 | value = '%s%s%s' % [value, @sep, timestamp] 125 | 126 | '%s%s%s' % [value, @sep, signature(value)] 127 | end 128 | 129 | def unsign(value, max_age: nil, return_timestamp: nil) 130 | sig_error = nil 131 | result = '' 132 | 133 | begin 134 | result = super(value) 135 | rescue BadSignature => e 136 | sig_error = e 137 | end 138 | 139 | if not result.include?(@sep) 140 | if sig_error 141 | raise sig_error 142 | else 143 | raise BadTimeSignature, 'timestamp missing' 144 | end 145 | end 146 | 147 | value, timestamp = Utils.rsplit(result, @sep) 148 | 149 | timestamp = Utils.bytes_to_int(Utils.base64_decode(timestamp)) 150 | 151 | if max_age 152 | age = get_timestamp - timestamp 153 | 154 | if age > max_age 155 | raise SignatureExpired, 'Signature age %s > %s seconds' % [age, max_age] 156 | end 157 | end 158 | 159 | if return_timestamp 160 | return value, timestamp_to_datetime(timestamp) 161 | else 162 | return value 163 | end 164 | end 165 | end 166 | end 167 | --------------------------------------------------------------------------------