Added encrypted fields for Credit Cards, etc... - PB
authorpbowen <pbowen>
Fri, 18 Mar 2005 19:21:30 +0000 (19:21 +0000)
committerpbowen <pbowen>
Fri, 18 Mar 2005 19:21:30 +0000 (19:21 +0000)
FS/FS/Conf.pm
FS/FS/Record.pm
FS/FS/cust_bill.pm
FS/FS/cust_main.pm
FS/bin/freeside-setup
httemplate/docs/upgrade10.html

index 61e74d0..e21e583 100644 (file)
@@ -302,6 +302,34 @@ httemplate/docs/config.html
   },
 
   {
+    'key'         => 'encryption',
+    'section'     => 'billing',
+    'description' => 'Enable encryption of credit cards.',
+    'type'        => 'checkbox',
+  },
+
+  {
+    'key'         => 'encryptionmodule',
+    'section'     => 'billing',
+    'description' => 'Use which module for encryption?',
+    'type'        => 'text',
+  },
+
+  {
+    'key'         => 'encryptionpublickey',
+    'section'     => 'billing',
+    'description' => 'Your RSA Public Key - Required if Encryption is turned on.',
+    'type'        => 'textarea',
+  },
+
+  {
+    'key'         => 'encryptionprivatekey',
+    'section'     => 'billing',
+    'description' => 'Your RSA Private Key - Including this will enable the "Bill Now" feature.  However if the system is compromised, a hacker can use this key to decode the stored credit card information.  This is generally not a good idea.',
+    'type'        => 'textarea',
+  },
+
+  {
     'key'         => 'business-onlinepayment',
     'section'     => 'billing',
     'description' => '<a href="http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment">Business::OnlinePayment</a> support, at least three lines: processor, login, and password.  An optional fourth line specifies the action or actions (multiple actions are separated with `,\': for example: `Authorization Only, Post Authorization\').    Optional additional lines are passed to Business::OnlinePayment as %processor_options.',
index 2a78c4d..e2efd17 100644 (file)
@@ -13,6 +13,7 @@ use DBIx::DBSchema 0.23;
 use FS::UID qw(dbh getotaker datasrc driver_name);
 use FS::SearchCache;
 use FS::Msgcat qw(gettext);
+use FS::Conf;
 
 use FS::part_virtual_field;
 
@@ -24,6 +25,12 @@ use Tie::IxHash;
 $DEBUG = 0;
 $me = '[FS::Record]';
 
+my $conf;
+my $rsa_module;
+my $rsa_loaded;
+my $rsa_encrypt;
+my $rsa_decrypt;
+
 #ask FS::UID to run this stuff for us later
 $FS::UID::callback{'FS::Record'} = sub { 
   $File::CounterFile::DEFAULT_DIR = "/usr/local/etc/freeside/counters.". datasrc;
@@ -379,32 +386,42 @@ sub qsearch {
       }
     }
   }
-  
+  my @return;
   if ( eval 'scalar(@FS::'. $table. '::ISA);' ) {
     if ( eval 'FS::'. $table. '->can(\'new\')' eq \&new ) {
       #derivied class didn't override new method, so this optimization is safe
       if ( $cache ) {
-        map {
+        @return = map {
           new_or_cached( "FS::$table", { %{$_} }, $cache )
         } values(%result);
       } else {
-        map {
+        @return = map {
           new( "FS::$table", { %{$_} } )
         } values(%result);
       }
     } else {
       warn "untested code (class FS::$table uses custom new method)";
-      map {
+      @return = map {
         eval 'FS::'. $table. '->new( { %{$_} } )';
       } values(%result);
     }
+
+    # Check for encrypted fields and decrypt them.
+    if ($conf->exists('encryption') && eval 'defined(@FS::'. $table . '::encrypted_fields)') {
+      foreach my $record (@return) {
+        foreach my $field (eval '@FS::'. $table . '::encrypted_fields') {
+          # Set it directly... This may cause a problem in the future...
+          $record->setfield($field, $record->decrypt($record->getfield($field)));
+        }
+      }
+    }
   } else {
     cluck "warning: FS::$table not loaded; returning FS::Record objects";
-    map {
+    @return = map {
       FS::Record->new( $table, { %{$_} } );
     } values(%result);
   }
-
+  return @return;
 }
 
 =item jsearch TABLE, HASHREF, SELECT, EXTRA_SQL, PRIMARY_TABLE, PRIMARY_KEY
@@ -598,6 +615,7 @@ otherwise returns false.
 
 sub insert {
   my $self = shift;
+  my $saved = {};
 
   my $error = $self->check;
   return $error if $error;
@@ -628,6 +646,17 @@ sub insert {
   }
 
   my $table = $self->table;
+
+  
+  # Encrypt before the database
+  if ($conf->exists('encryption') && defined(eval '@FS::'. $table . 'encrypted_fields')) {
+    foreach my $field (eval '@FS::'. $table . '::encrypted_fields') {
+      $self->{'saved'} = $self->getfield($field);
+      $self->setfield($field, $self->enrypt($self->getfield($field)));
+    }
+  }
+
+
   #false laziness w/delete
   my @real_fields =
     grep defined($self->getfield($_)) && $self->getfield($_) ne "",
@@ -741,6 +770,12 @@ sub insert {
 
   dbh->commit or croak dbh->errstr if $FS::UID::AutoCommit;
 
+  # Now that it has been saved, reset the encrypted fields so that $new 
+  # can still be used.
+  foreach my $field (keys %{$saved}) {
+    $self->setfield($field, $saved->{$field});
+  }
+
   '';
 }
 
@@ -847,6 +882,8 @@ sub replace {
   my $new = shift;
   my $old = shift;  
 
+  my $saved = {};
+
   if (!defined($old)) { 
     warn "[debug]$me replace called with no arguments; autoloading old record\n"
      if $DEBUG;
@@ -871,6 +908,14 @@ sub replace {
 
   my $error = $new->check;
   return $error if $error;
+  
+  # Encrypt for replace
+  if ($conf->exists('encryption') && defined(eval '@FS::'. $new->table . 'encrypted_fields')) {
+    foreach my $field (eval '@FS::'. $new->table . '::encrypted_fields') {
+      $saved->{$field} = $new->getfield($field);
+      $new->setfield($field, $new->encrypt($new->getfield($field)));
+    }
+  }
 
   #my @diff = grep $new->getfield($_) ne $old->getfield($_), $old->fields;
   my %diff = map { ($new->getfield($_) ne $old->getfield($_))
@@ -1000,6 +1045,12 @@ sub replace {
 
   dbh->commit or croak dbh->errstr if $FS::UID::AutoCommit;
 
+  # Now that it has been saved, reset the encrypted fields so that $new 
+  # can still be used.
+  foreach my $field (keys %{$saved}) {
+    $new->setfield($field, $saved->{$field});
+  }
+
   '';
 
 }
@@ -1653,6 +1704,70 @@ sub _dump {
   } (fields($self->table)) );
 }
 
+sub encrypt {
+  my ($self, $value) = @_;
+  my $encrypted;
+  if ($conf->exists('encryption') && !$self->is_encrypted($value)) {
+    $self->loadRSA;
+    if (ref($rsa_encrypt) =~ /::RSA/) { # We Can Encrypt
+      # RSA doesn't like the empty string so let's pack it up
+      # The database doesn't like the RSA data so uuencode it
+      my $length = length($value)+1;
+      $encrypted = pack("u*",$rsa_encrypt->encrypt(pack("Z$length",$value)));
+    }
+  }
+  return $encrypted;
+}
+
+sub is_encrypted {
+  my ($self, $value) = @_;
+  # Possible Bug - Some work may be required here....
+
+  if (length($value) > 80) {
+    return 1;
+  } else {
+    return 0;
+  }
+}
+
+sub decrypt {
+  my ($self,$value) = @_;
+  my $decrypted = $value; # Will return the original value if it isn't encrypted or can't be decrypted.
+  if ($conf->exists('encryption') && $self->is_encrypted($value)) {
+    $self->loadRSA;
+    if (ref($rsa_decrypt) =~ /::RSA/) {
+      my $encrypted = unpack ("u*", $value);
+      $decrypted =  unpack("Z*", $rsa_decrypt->decrypt($encrypted));
+    }
+  }
+  return $decrypted;
+}
+
+sub loadRSA {
+    my $self = shift;;
+    #Initialize the Module
+    if (!$conf->exists('encryptionmodule')) {
+       carp "warning: There is no Encryption Module Defined!";
+       return;
+    }
+    $rsa_module = $conf->config('encryptionmodule');
+    if (!$rsa_loaded) {
+       eval ("require $rsa_module"); # No need to import the namespace
+       $rsa_loaded++;
+    }
+    # Initialize Encryption
+    if ($conf->exists('encryptionpublickey') && $conf->config('encryptionpublickey') ne '') {
+      my $public_key = join("\n",$conf->config('encryptionpublickey'));
+      $rsa_encrypt = $rsa_module->new_public_key($public_key);
+    }
+    
+    # Intitalize Decryption
+    if ($conf->exists('encryptionprivatekey') && $conf->config('encryptionprivatekey') ne '') {
+      my $private_key = join("\n",$conf->config('encryptionprivatekey'));
+      $rsa_decrypt = $rsa_module->new_private_key($private_key);
+    }
+}
+
 sub DESTROY { return; }
 
 #sub DESTROY {
index 3355252..e70bb32 100644 (file)
@@ -756,7 +756,7 @@ sub batch_card {
     'state'    => $cust_main->getfield('state'),
     'zip'      => $cust_main->getfield('zip'),
     'country'  => $cust_main->getfield('country'),
-    'cardnum'  => $cust_main->getfield('payinfo'),
+    'cardnum'  => $cust_main->payinfo,
     'exp'      => $cust_main->getfield('paydate'),
     'payname'  => $cust_main->getfield('payname'),
     'amount'   => $self->owed,
index be81f33..5db7a48 100644 (file)
@@ -1,7 +1,7 @@
 package FS::cust_main;
 
 use strict;
-use vars qw( @ISA @EXPORT_OK $conf $DEBUG $import );
+use vars qw( @ISA @EXPORT_OK $conf $DEBUG $import @encrypted_fields);
 use vars qw( $realtime_bop_decline_quiet ); #ugh
 use Safe;
 use Carp;
@@ -53,6 +53,8 @@ $DEBUG = 0;
 
 $import = 0;
 
+@encrypted_fields = ('payinfo', 'paycvv');
+
 #ask FS::UID to run this stuff for us later
 #$FS::UID::callback{'FS::cust_main'} = sub { 
 install_callback FS::UID sub { 
@@ -175,11 +177,84 @@ FS::Record.  The following fields are currently supported:
 
 =item ship_fax - phone (optional)
 
-=item payby - I<CARD> (credit card - automatic), I<DCRD> (credit card - on-demand), I<CHEK> (electronic check - automatic), I<DCHK> (electronic check - on-demand), I<LECB> (Phone bill billing), I<BILL> (billing), I<COMP> (free), or I<PREPAY> (special billing type: applies a payment from a prepaid card - see L<FS::prepay_credit> - and sets billing type to I<BILL>)
+=item payby 
+
+I<CARD> (credit card - automatic), I<DCRD> (credit card - on-demand), I<CHEK> (electronic check - automatic), I<DCHK> (electronic check - on-demand), I<LECB> (Phone bill billing), I<BILL> (billing), I<COMP> (free), or I<PREPAY> (special billing type: applies a credit - see L<FS::prepay_credit> and sets billing type to I<BILL>)
+
+=item payinfo 
+
+Card Number, P.O., comp issuer (4-8 lowercase alphanumerics; think username) or prepayment identifier (see L<FS::prepay_credit>)
+
+=cut 
+
+sub payinfo {
+  my($self,$payinfo) = @_;
+  if ( defined($payinfo) ) {
+    $self->paymask($payinfo);
+    $self->setfield('payinfo', $payinfo); # This is okay since we are the 'setter'
+  } else {
+    $payinfo = $self->getfield('payinfo'); # This is okay since we are the 'getter'
+    return $payinfo;
+  }
+}
+
+
+=item paycvv
+Card Verification Value, "CVV2" (also known as CVC2 or CID), the 3 or 4 digit number on the back (or front, for American Express) of the credit card
+
+=cut
+
+=item paymask - Masked payment type
+
+=over 4 
+
+=item Credit Cards
+
+Mask all but the last four characters.
+
+=item Checks
+
+Mask all but last 2 of account number and bank routing number.
+
+=item Others
+
+Do nothing, return the unmasked string.
+
+=back
+
+=cut 
+
+sub paymask {
+  my($self,$value)=@_;
+
+  # If it doesn't exist then generate it
+  my $paymask=$self->getfield('paymask');
+  if (!defined($value) && (!defined($paymask) || $paymask eq '')) {
+    $value = $self->payinfo;
+  }
+
+  if ( defined($value) && !$self->is_encrypted($value)) {
+    my $payinfo = $value;
+    my $payby = $self->payby;
+    if ($payby eq 'CARD' || $payby eq 'DCARD') { # Credit Cards (Show last four)
+      $paymask = 'x'x(length($payinfo)-4). substr($payinfo,(length($payinfo)-4));
+    } elsif ($payby eq 'CHEK' ||
+             $payby eq 'DCHK' ) { # Checks (Show last 2 @ bank)
+      my( $account, $aba ) = split('@', $payinfo );
+      $paymask = 'x'x(length($account)-2). substr($account,(length($account)-2))."@".$aba;
+    } else { # Tie up loose ends
+      $paymask = $payinfo;
+    }
+    $self->setfield('paymask', $paymask); # This is okay since we are the 'setter'
+  } else {
+    $paymask = 'N/A';
+  }
+  return $paymask;
+}
+
 
-=item payinfo - card number, P.O., comp issuer (4-8 lowercase alphanumerics; think username) or prepayment identifier (see L<FS::prepay_credit>)
 
-=item paycvv - Card Verification Value, "CVV2" (also known as CVC2 or CID), the 3 or 4 digit number on the back (or front, for American Express) of the credit card
 
 =item paydate - expiration date, mm/yyyy, m/yyyy, mm/yy or m/yy
 
@@ -602,6 +677,16 @@ sub replace {
   local $SIG{TSTP} = 'IGNORE';
   local $SIG{PIPE} = 'IGNORE';
 
+  # If the mask is blank then try to set it - if we can...
+  if (!defined($self->paymask) && $self->paymask eq '') {
+    $self->paymask($self->payinfo);
+  }
+
+  # We absolutely have to have an old vs. new record to make this work.
+  if (!defined($old)) {
+    $old = qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
+  }
+
   if ( $self->payby eq 'COMP' && $self->payby ne $old->payby
        && $conf->config('users-allow_comp')                  ) {
     return "You are not permitted to create complimentary accounts."
@@ -695,7 +780,7 @@ sub queue_fuzzyfiles_update {
 
 Checks all fields to make sure this is a valid customer record.  If there is
 an error, returns the error, otherwise returns false.  Called by the insert
-and repalce methods.
+and replace methods.
 
 =cut
 
@@ -826,9 +911,19 @@ sub check {
 
   $self->payby =~ /^(CARD|DCRD|CHEK|DCHK|LECB|BILL|COMP|PREPAY)$/
     or return "Illegal payby: ". $self->payby;
+
+  # If it is encrypted and the private key is not availaible then we can't
+  # check the credit card.
+
+  my $check_payinfo = 1;
+
+  if ($self->is_encrypted($self->payinfo)) {
+    $check_payinfo = 0;
+  }
+
   $self->payby($1);
 
-  if ( $self->payby eq 'CARD' || $self->payby eq 'DCRD' ) {
+  if ( $check_payinfo && ($self->payby eq 'CARD' || $self->payby eq 'DCRD')) {
 
     my $payinfo = $self->payinfo;
     $payinfo =~ s/\D//g;
@@ -856,7 +951,7 @@ sub check {
       }
     }
 
-  } elsif ( $self->payby eq 'CHEK' || $self->payby eq 'DCHK' ) {
+  } elsif ($check_payinfo && ( $self->payby eq 'CHEK' || $self->payby eq 'DCHK' )) {
 
     my $payinfo = $self->payinfo;
     $payinfo =~ s/[^\d\@]//g;
@@ -2478,15 +2573,17 @@ sub paydate_monthyear {
 
 =item payinfo_masked
 
-Returns a "masked" payinfo field with all but the last four characters replaced
-by 'x'es.  Useful for displaying credit cards.
+Returns a "masked" payinfo field appropriate to the payment type.  Masked characters are replaced by 'x'es.  Use this to display publicly accessable account Information.
+
+Credit Cards - Mask all but the last four characters.
+Checks - Mask all but last 2 of account number and bank routing number.
+Others - Do nothing, return the unmasked string.
 
 =cut
 
 sub payinfo_masked {
   my $self = shift;
-  my $payinfo = $self->payinfo;
-  'x'x(length($payinfo)-4). substr($payinfo,(length($payinfo)-4));
+  return $self->paymask;
 }
 
 =item invoicing_list [ ARRAYREF ]
index 74aa5e2..38b9166 100755 (executable)
@@ -487,8 +487,9 @@ sub tables_hash_hack {
         'ship_night',    'varchar', 'NULL', 20,
         'ship_fax',      'varchar', 'NULL', 12,
         'payby',    'char', '',     4,
-        'payinfo',  'varchar', 'NULL', $char_d,
-        'paycvv',   'varchar', 'NULL', 4,
+        'payinfo',  'varchar', 'NULL', 512,
+        'paycvv',   'varchar', 'NULL', 512,
+       'paymask', 'varchar', 'NULL', $char_d,
         #'paydate',  @date_type,
         'paydate',  'varchar', 'NULL', 10,
         'payname',  'varchar', 'NULL', $char_d,
index 76c4933..85b36a8 100644 (file)
@@ -278,8 +278,12 @@ ALTER TABLE agent ADD username varchar(80) NULL;
 ALTER TABLE h_agent ADD username varchar(80) NULL;
 ALTER TABLE agent ADD _password varchar(80) NULL;
 ALTER TABLE h_agent ADD _password varchar(80) NULL;
-ALTER TABLE cust_main ADD paycvv varchar(4) NULL;
-ALTER TABLE h_cust_main ADD paycvv varchar(4) NULL;
+ALTER TABLE cust_main ADD paycvv varchar(512) NULL;
+ALTER TABLE h_cust_main ADD paycvv varchar(512) NULL;
+ALTER TABLE cust_main ALTER COLUMN payinfo varchar(512) NULL;
+ALTER TABLE h_cust_main ALTER COLUMN payinfo varchar(512) NULL;
+ALTER TABLE cust_main ADD paymask varchar(80) NULL;
+ALTER TABLE h_cust_main ADD paymask varchar(80) NULL;
 ALTER TABLE part_referral ADD disabled char(1) NULL;
 ALTER TABLE h_part_referral ADD disabled char(1) NULL;
 CREATE INDEX part_referral1 ON part_referral ( disabled );