summaryrefslogtreecommitdiff
path: root/certs/app/models/client_certificate.rb
blob: 0b1e43f6e827074c826c0427c49af22db7983bf3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
#
# Model for certificates stored in CouchDB.
#
# This file must be loaded after Config has been loaded.
#
require 'base64'
require 'digest/md5'
require 'openssl'
require 'certificate_authority'
require 'date'

class ClientCertificate < CouchRest::Model::Base

  use_database 'client_certificates'

  timestamps!

  property :key, String                          # the client private RSA key
  property :cert, String                         # the client x509 certificate, signed by the CA
  property :valid_until, Time                    # expiration time of the client certificate
  property :random, Float, :accessible => false  # used to help pick a random cert by the webapp

  before_validation :generate, :set_random, :on => :create

  validates :key, :presence => true
  validates :cert, :presence => true
  validates :random, :presence => true
  validates :random, :numericality => {:greater_than => 0, :less_than => 1}

  design do
    view :by_random
  end

  class << self
    def sample
      self.by_random.startkey(rand).first || self.by_random.first
    end

    def pick_from_pool
      cert = self.sample
      raise RECORD_NOT_FOUND unless cert
      cert.destroy
      return cert
    rescue RESOURCE_NOT_FOUND
      retry if self.by_random.count > 0
      raise RECORD_NOT_FOUND
    end

    def valid_attributes_hash
      {:key => "ABCD", :cert => "A123"}
    end
  end

  #
  # generate the private key and client certificate
  #
  def generate
    cert = CertificateAuthority::Certificate.new

    # set subject
    cert.subject.common_name = random_common_name

    # set expiration
    self.valid_until = months_from_yesterday(APP_CONFIG[:client_cert_lifespan])
    cert.not_before = yesterday
    cert.not_after = self.valid_until

    # generate key
    cert.serial_number.number = cert_serial_number
    cert.key_material.generate_key(APP_CONFIG[:client_cert_bit_size])

    # sign
    cert.parent = ClientCertificate.root_ca
    cert.sign! client_signing_profile

    self.key = cert.key_material.private_key.to_pem
    self.cert = cert.to_pem
  end

  private

  def set_random
    self.random = rand
  end

  def self.root_ca
    @root_ca ||= begin
                   crt = File.read(APP_CONFIG[:ca_cert_path])
                   key = File.read(APP_CONFIG[:ca_key_path])
                   openssl_cert = OpenSSL::X509::Certificate.new(crt)
                   cert = CertificateAuthority::Certificate.from_openssl(openssl_cert)
                   cert.key_material.private_key = OpenSSL::PKey::RSA.new(key, APP_CONFIG[:ca_key_password])
                   cert
                 end
  end

  #
  # For cert serial numbers, we need a non-colliding number less than 160 bits.
  # md5 will do nicely, since there is no need for a secure hash, just a short one.
  # (md5 is 128 bits)
  #
  def cert_serial_number
    Digest::MD5.hexdigest("#{rand(10**10)} -- #{Time.now}").to_i(16)
  end

  #
  # for the random common name, we need a text string that will be unique across all certs.
  # ruby 1.8 doesn't have a built-in uuid generator, or we would use SecureRandom.uuid
  #
  def random_common_name
    cert_serial_number.to_s(36)
  end

  def client_signing_profile
    {
      "digest" => APP_CONFIG[:client_cert_hash],
      "extensions" => {
      "keyUsage" => {
      "usage" => ["digitalSignature"]
    },
      "extendedKeyUsage" => {
      "usage" => ["clientAuth"]
    }
    }
    }
  end

  ##
  ## TIME HELPERS
  ##
  ## note: we use 'yesterday' instead of 'today', because times are in UTC, and some people on the planet
  ## are behind UTC.
  ##

  def yesterday
    t = Time.now - 24*24*60
    Time.utc t.year, t.month, t.day
  end

  def months_from_yesterday(num)
    t = yesterday
    date = Date.new t.year, t.month, t.day
    date = date >> num # >> is months in the future operator
    Time.utc date.year, date.month, date.day
  end

end