From: pbowen Date: Fri, 18 Mar 2005 19:21:30 +0000 (+0000) Subject: Added encrypted fields for Credit Cards, etc... - PB X-Git-Tag: BEFORE_FINAL_MASONIZE~636 X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=commitdiff_plain;h=a638ae8bdba35169f61f2729d8f3491496992b55 Added encrypted fields for Credit Cards, etc... - PB --- diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index 61e74d0ea..e21e58322 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -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' => 'Business::OnlinePayment 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.', diff --git a/FS/FS/Record.pm b/FS/FS/Record.pm index 2a78c4dc4..e2efd1731 100644 --- a/FS/FS/Record.pm +++ b/FS/FS/Record.pm @@ -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 { diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index 33552523a..e70bb3247 100644 --- a/FS/FS/cust_bill.pm +++ b/FS/FS/cust_bill.pm @@ -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, diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index be81f333c..5db7a482c 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -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 (credit card - automatic), I (credit card - on-demand), I (electronic check - automatic), I (electronic check - on-demand), I (Phone bill billing), I (billing), I (free), or I (special billing type: applies a payment from a prepaid card - see L - and sets billing type to I) +=item payby + +I (credit card - automatic), I (credit card - on-demand), I (electronic check - automatic), I (electronic check - on-demand), I (Phone bill billing), I (billing), I (free), or I (special billing type: applies a credit - see L and sets billing type to I) + +=item payinfo + +Card Number, P.O., comp issuer (4-8 lowercase alphanumerics; think username) or prepayment identifier (see L) + +=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) -=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 ] diff --git a/FS/bin/freeside-setup b/FS/bin/freeside-setup index 74aa5e2f0..38b916613 100755 --- a/FS/bin/freeside-setup +++ b/FS/bin/freeside-setup @@ -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, diff --git a/httemplate/docs/upgrade10.html b/httemplate/docs/upgrade10.html index 76c49330c..85b36a8db 100644 --- a/httemplate/docs/upgrade10.html +++ b/httemplate/docs/upgrade10.html @@ -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 );