Encrypting Login Password without SSL in Ruby on Rails

Posted by jkahn on March 18, 2008 · 4 mins read

For a personal project, I am building a Rails site that has an administration section. Of course, I don't want any nefarious person who snoops my network traffic to be able to login. SSL isn't an easy option because (1) my site is on a shared host, (2) I don't want to pay for an SSL certificate, and (3) I would prefer that my users do not need to accept a self-signed certificate.

Given these conditions, I felt that a private/public key pair would successfully obfuscate login credentials without SSL. At a high level, my Rails application generates a 1024-bit RSA key on the fly and shares a public version with the client. The client utilizes an open source RSA library for JavaScript to encrypt the credentials on the client before sending them back to the server, which then uses the private key to decrypt them. I'm not an encryption expert, but I think the worst that could happen is that someone could decrypt the credentials for the one request they capture (feel free to correct me though).

Let's get to the code. To set the situation, I am following REST conventions for authentication, so I have a SessionsController with a new action and a create action. The former is responsible for setting up the login and the latter for processing the user's input.

First, the "new" action, which creates the RSA key, provides the public components to the view template, and stores the key (in PEM format) in session:

  def new
    key = OpenSSL::PKey::RSA.new(1024)
    @public_modulus  = key.public_key.n.to_s(16)
    @public_exponent = key.public_key.e.to_s(16)
    session[:key] = key.to_pem

Then in the view template ("new.html.erb"), we provide the public modulus and exponent (the necessary component of the public key) as well as input forms for the username and password:

  <%= javascript_include_tag('rsa/jsbn', 'rsa/prng4', 'rsa/rng', 'rsa/rsa', 'rsa/base64', :cache => true) %>

  <% form_tag session_path, :id => 'login' do -%>
    <legend>Please Login</legend>
    <label for="login" class="required">Login</label>
    <%= text_field_tag :username, params[:username] %><br />
    <label for="password" class="required">Password</label>
    <%= password_field_tag :upassword, params[:upassword] %><br />
    <%= hidden_field_tag :password, '' %>
  <%= submit_tag 'Log in' %>
  <% end -%>

  <%= hidden_field_tag :public_modulus, @public_modulus %>
  <%= hidden_field_tag :public_exponent, @public_exponent %>

Two things to note here. First, we are including the four necessary JavaScript libraries on this page only. Second, we use a hidden field to store/commit the password - this field is populate via JavaScript.

My application utilizes jQuery, so attaching a function to encrypt the password before form submission is straightforward:

  $(document).ready(function() {
    $("form#login").submit(function() {
      var rsa = new RSAKey();
      rsa.setPublic($('#public_modulus').val(), $('#public_exponent').val());
      var res = rsa.encrypt($('#upassword').val());
      if (res) {
        return true;
      return false;

Before submission occurs, we encrypt the value of the "upassword" field, store an encrypted Base64 version in "password," and clear "upassword." If there is a problem, the form is not submitted.

On the server-side, this form is submitted to the SessionsController#create action:

  def create
    key = OpenSSL::PKey::RSA.new(session[:key])
    password = key.private_decrypt(Base64.decode64(params[:password]))
    user = User.authenticate(params[:username], password)
    if user
      reset_session  # reset session after login
      session[:user_id] = user.id
      flash[:notice] = "Welcome back, #{user.username}"
      redirect_to admin_url
      flash[:error] = 'Invalid username/password entered'
      new and render :action => 'new'

Here, we pull the key out of session and use it to decrypt the form input before attempting to authenticate the user. It is important to note the the private_decrypt method wants binary data, so we need decode the Base64 text passed in the request (using Base64 seemed more appropriate than binary data here). After the authenticate method is called, things proceed as usual.

So far, this is working fairly well. There are a few options for improvement - perhaps a before_filter to preprocess any encrypted data. I'd be interested in hearing other ideas on this topic as well.