#!perl -w package App::CamelPKI::CADB; use warnings; use strict; =head1 NAME B - Store L datas in a SQLite database =head1 SYNOPSIS =for My::Tests::Below "synopsis" use App::CamelPKI::CADB; my $cadb = load App::CamelPKI::CADB($dir); my $serial = $cadb->next_serial("certificate"); # ... making a $certificate with $serial ... $cadb->add($cert, foo => "bar", baz => [ "quux", "bloggs" ]); $cadb->revoke($certificate, -revocation_reason => "keyCompromise", -compromise_time => "20070313104800Z"); $cadb->commit(); for(my $cursor = $cadb->search(-initially_valid_at => "now", -revoked => 1); $cursor->has_more; $cursor->next) { my $cert = $cursor->certificate; my %infos = $cursor->infos; my $revocation_time = $cursor->revocation_time; my $revocation_reason = $cursor->revocation_reason; my $compromise_time = $cursor->compromise_time; # ... making the CRL ... } =for My::Tests::Below "synopsis" end =head1 DESCRIPTION This class modelizes a CA database; this database store issued certificates, nominative datas used for their creation, revocation status of these certificates, and some incremental series for CRL and certificates serial numbers. For now, Camel-PKI only knows howto store certificates in an SQLite database. =head1 CAPABILITY DISCIPLINE Possessing an I instance equates privilege to perform all non-destructive write operations on this database; however, no deletion of certificates is possible. The L method returns a read-only version on this database. The L method is restricted (see L), despite it not being a class method; the result is that it cannot be called at all when I is active, which is what we want (C is not meant to be called at all in production). =cut use File::Path qw(mkpath); use File::Spec::Functions qw(catfile catdir); use SQL::Translator 0.07; # On behalf of ->deploy() use App::CamelPKI::Error; use App::CamelPKI::RestrictedClassMethod ':Restricted'; use App::CamelPKI::Time; use App::CamelPKI::Certificate; =head1 METHODS =head2 initdb($dir) Populates $dir, a string containing the name of a directory, with an empty database. Returns immediately if $dir already exists. =cut sub initdb { my ($class, $dir) = @_; my $db_file = $class->_db_file($dir); my $dsn = $class->_dsn($dir); if (-f $db_file) { $class->_connect($dir); # Acts as a functional test return; } if (! -d $dir) { mkpath($dir) or throw App::CamelPKI::Error::IO("cannot create path", -IOfile => $dir); } $class->_connect($dir)->deploy(); return; } =head2 load($dir) Restricted constructor (see L). Loads the database from the $dir directory and returns a read-write object. =cut sub load : Restricted { my ($class, $dir) = @_; $class->initdb($dir); my $self = bless { dbix => $class->_connect($dir), }, $class; $self->{dbix}->txn_begin; if (defined(our $debugsub)) { # See L $self->{dbix}->storage->debugobj (App::CamelPKI::CADB::_Logger->new($debugsub)); $self->{dbix}->storage->debug(1); } return $self; } =head2 add($cert, %infos) Add a certificate to the database. $cert is an instance of L which must not be already existing. %infos is a table of nominative informations, dealt as an opaque chain, where keys must been validaded by the regular expression qr/[a-z0-9_]+/, and values are character chains or references to a character chains table. Semantics on these informations is at the caller's choice; from the I point of view, these informations can be used as search expression in L, and be consulted using L in L. =cut sub add { throw App::CamelPKI::Error::Internal("WRONG_NUMBER_ARGS") if (@_ % 2); my ($self, $cert, %infos) = @_; my $dercert = $cert->serialize(-format => "DER"); throw App::CamelPKI::Error::Database("Certificate already entered") if $self->{dbix}->resultset("Certificate") ->search({der => $dercert})->count; my $certid = $self->{dbix}->resultset("Certificate")->create ({der => $dercert, serial => $cert->get_serial, not_before => $cert->get_notBefore, not_after => $cert->get_notAfter, })->id; foreach my $key (keys %infos) { foreach my $val (ref($infos{$key}) eq "ARRAY" ? @{$infos{$key}} : ($infos{$key})) { $self->{dbix}->resultset("CertInfo") ->create({certid => $certid, key => $key, val => $val}); } } 1; } =head2 search(%criteria) Search certificates that were added (L) precedently, using %criteria criteria in a conjonctive way (ie, dealt with the "AND" operator). If %criteria does not contain any key C<< -initially_valid_at >> and C<< -revoked >>, then %criteria is supposed to implicitly contain -initially_valid_at => "now", -revoked => 0 to make I returns only valid certificates (in the RFC3280 way), if not stated otherwise. In a more general way, keys and values for %criteria are: =over =item I<< -certificate => $cert >> Renvoie uniquement le certificat identique à $cert, une instance de L. =item I<< -initially_valid_at => $time >> Returns only certificates that were initially scheduled to be valid at $time date, regardless of whether they have been revoked (but see also C<< -revoked >>). In other words, returns certificates that match notBefore <= $time && $time <= notAfter $time is either an object of class L, a date in the "zulu" format (yyyymmddhhmmssZ), or the special string "now". =item I<< -revoked => 1 >> Returns only revoked certificates, ie those for which the most recent call to L did not specify C<< -reason => "removeFromCRL" >>. =item I<< -revoked => 0 >> Returns only valid certificates, or those that were un-revoked by passing C<< -reason => "removeFromCRL" >> to L. =item I<< -revoked => undef >> Search certificates without consideration for their revocation status. Used to cancel the effect of the implicit value mentioned above. =item I<< -serial => serial >> Search certificates for the certifiate serial. =item I<< $infokey => $infoval >> where $infokey doesn't start by a hyphen (-): returns only certificate(s) that had $infokey and $infoval among their %infos at the time they where added using L. $infoval may be undef, indicating that any value for $infokey in %infos is acceptable. =back The return value in list context is a list of L object class. In scalar context, a B of the L class is returned. =cut sub search { throw App::CamelPKI::Error::Internal("WRONG_NUMBER_ARGS") unless (@_ % 2); my ($self, %searchkeys) = @_; if (! exists $searchkeys{-initially_valid_at} && ! exists $searchkeys{-revoked}) { $searchkeys{-initially_valid_at} = "now"; $searchkeys{-revoked} = 0; } # Using DBIx::Class release 0.07003, the join list to execute don't # support iterative enumeration, unlike the WHERE clauses. We must # use this dirty kludge, that violates DBIx::Class encapsulation in # two points: my @joins; my $cursor = $self->{dbix}->resultset("Certificate") ->search({ }, { join => \@joins }); # Encaps violation # number 1: we will be modifying \@joins later while(my ($k, $v) = each %searchkeys) { if ($k !~ m/^-/) { push(@joins, "infos"); my $qualifier = (@joins == 1) ? "" : "_" . scalar(@joins); # encapsulation violation number 2: we try to guess the # way DBIx::Class disambiguates the join column names. $cursor = $cursor->search ({ "infos${qualifier}.key" => $k, ( defined($v) ? ("infos${qualifier}.val" => $v) : () ), }); } elsif ($k eq "-certificate") { throw App::CamelPKI::Error::Internal("INCORRECT_ARGS") unless eval { $v->isa("App::CamelPKI::Certificate") }; $cursor = $cursor->search ( { der => $v->serialize(-format => "DER") } ); } elsif ($k eq "-initially_valid_at") { $v = App::CamelPKI::Time->parse($v); $cursor = $cursor->search ( { not_before => { "<=", $v->zulu }, not_after => { " >=", $v->zulu }} ); } elsif ($k eq "-serial") { $cursor = $cursor->search ( { serial => { "=", $v } } ); } elsif ($k eq "-revoked") { if (! defined($v)) { # no-op } elsif ($v) { # Only revoked certificates $cursor = $cursor->search # Yes, { "!=", undef } correctly translates to "IS # NOT NULL". Props to SQL::Abstract! ( [ -and => { revocation_time => { "!=", undef } }, { revocation_reason => { "!=", "removeFromCRL" } } ] ); } else { # Only *not* revoked certificates $cursor = $cursor->search ( [ -or => { revocation_time => { "==", undef } }, { revocation_reason => "removeFromCRL" } ] ); } } else { throw App::CamelPKI::Error::Internal ("INCORRECT_ARGS", -details => "Unknown search key $k"); } } $cursor = (ref($self) . "::Cursor")->_new ($cursor, $self->{dbix}->resultset("CertInfo")); return $cursor if ! wantarray; my @retval; for(; $cursor->has_more; $cursor->next) { push(@retval, $cursor->certificate); } return @retval; } =head2 revoke($cert, %options) Mark a certificate as revoked, for the CA to know it must been included in the next CRLs. $cert is an instance of L. Known Named options are: =over =item I<< -revocation_time => $time >> The revocation date, in "zulu" format (yyyymmddhhmmssZ). By default, the current date is used. =item I<< -revocation_reason => $reason >> =item I<< -hold_instruction => $oid >> (B) =item I<< -hold_instruction => $string >> (B) =item I<< -compromise_time => $time >> Values of the extensions of the same name in the CRL, as documented in L. By default, these extensions are omitted. Using C as C<$reason> cancels the revocation of this certificate. Please note that values for keys C<-hold_instruction> and C<-revocation_reason> undergo canonicalization, so that they may read out differently from the L when fetched again. =back =cut sub revoke { throw App::CamelPKI::Error::Internal("WRONG_NUMBER_ARGS") if (@_ % 2); my ($self, $cert, %options) = @_; throw App::CamelPKI::Error::Internal("UNIMPLEMENTED") if ($options{-hold_instruction}); # ... And thus, I can just skip field canonicalization issues for # now! my $cursor = $self->{dbix}->resultset("Certificate") ->search({ der => $cert->serialize(-format => "DER") }); throw App::CamelPKI::Error::Database ("Unknown certificate", -certificate => $cert) unless defined(my $row = $cursor->next); throw App::CamelPKI::Error::Database ("Duplicate certificate", -certificate => $cert) if $cursor->next; $row->revocation_time (App::CamelPKI::Time->parse($options{-revocation_time} || "now") ->zulu); $row->compromise_time (App::CamelPKI::Time->parse($options{-compromise_time})->zulu) if (exists $options{-compromise_time}); $row->revocation_reason($options{-revocation_reason}) if (exists $options{-revocation_reason}); $row->update; 1; } =head2 next_serial($seqname) Increments the sequence named $seqname and returns its new value. $seqname may be any string matching m/^[a-z]+$/i, at the caller's choice. Sequences start at 2. =cut sub next_serial { my ($self, $seqname) = @_; my $row = $self->{dbix}->resultset("Sequence")->find_or_new ({name => $seqname}); if (! $row->in_storage) { $row->val(2); $row->insert(); return 2; } else { my $retval = $row->val + 1; $row->val($retval); $row->update(); return $retval; } } =head2 commit() Commits all modifications made with L, L and L since the construction of this object, or the previous call to I, whichever is latest. B. =cut sub commit { my ($self) = @_; $self->{dbix}->txn_commit(); $self->{dbix}->txn_begin(); } =head2 max_serial($seqname) Returns (an approximation of) the current status of the sequence named $seqname, ie an integer which is guaranteed to be superior or equal to all previous values previously returned by L, and strictly inferior to all values that will be returned in the future. =cut sub max_serial { my ($self, $seqname) = @_; my $row = $self->{dbix}->resultset("Sequence")->find_or_new ({name => $seqname}); return ($row->in_storage ? $row->val : 0); } =head2 facet_readonly() Returns a read-only copy of the database object: only L and L methods are available. =cut sub facet_readonly { my ($self) = @_; return bless { delegate => $self }, "App::CamelPKI::CADB::FacetReadOnly"; package App::CamelPKI::CADB::FacetReadOnly; use Class::Facet from => "App::CamelPKI::CADB", on_error => \&App::CamelPKI::Error::Privilege::on_facet_error, delegate => [qw(search max_serial)]; } =head2 debug_statements($debugsub) This restricted method (see L) installs $debugsub as the SQL request observer on all I objects constructed later. This method will be called thusly for each SQL request: $debugsub->($sql, @bind_values); This mecanism is only destined for testing purposes; it should not be used in production. =cut sub debug_statements : Restricted { my ($class, $debugsub) = @_; our $debugsub = $debugsub; } =head1 App::CamelPKI::CADB::Cursor This class models a read only SQL cursor equivalent; instancies may be constructed using L. An attentive reader will probably understand that's a fairly trivial derivation of the L API; However there is no reason to return directly an instance of I in L because it will violate the encapsulation of I. Consequence could be a induced coupling (it should be impossible to refactor the scheme without breaking the whole calling code), and worse, a privilege escape leading to a security breach (because it's also possible to write with a I cursor). =cut package App::CamelPKI::CADB::Cursor; =begin internals =head2 _new($realcursor, $infos_resultset) Constructor. $realcursor is an instance of L coming from L which represents all certificates to be enumerated; $infos_resultset is an instance of L coming from L which represents the B of B in database, and it's the job of I to restrict this search to what it is interresting. =cut sub _new { my ($class, $cursor, $infos_resultset) = @_; return bless { index => 0, cursor => $cursor, infos_set => $infos_resultset, }, $class; } =head2 _fetch_certificates() Execute the SQL request that rocks, grab in one shot all certificates and their revocation informations, and cache them in $self->{certs} which become a list of objects form the L. Rationale for this 'slurpy' behavior: SQLite does not appreciate to have a statement in progress when you close the connection (symptom: C). That's why we cannot use the "streaming" mode of DBIx::Class without a complex system of statement caching in one time, I'm just too lazy to implement right now... =cut sub _fetch_certificates { my ($self) = @_; return if $self->{certs}; $self->{certs} = []; $self->{cursor}->reset; while(my $row = $self->{cursor}->next) { push (@{$self->{certs}}, $row); } return; } =head2 _fetch_infos() Functions as L (which is called before each operation of this method), grab in one shot all nominative informations about certificates, and cache them in $self->{infos} in the form of a reference to a list of same length as @{$self->{certs}} and ordored the same way, which contains as many multi-valued hash tables to store informations as passed by L at the time of the respective certificates insertion. =cut sub _fetch_infos { my ($self) = @_; return if $self->{infos}; $self->_fetch_certificates; my %infos; # FIXME: we could repeat the $self->{cursor} SQL instead. # This could helps to play too much DBI placeholders... my $infocursor = $self->{infos_set}->search ({ certid => { in => [ map { $_->certid } @{$self->{certs}} ] }}); $infocursor->reset; while(my $info = $infocursor->next) { push(@{$infos{$info->certid}->{$info->key}}, $info->val); } $self->{infos} = [ map { ($infos{$_->certid} || {}) } @{$self->{certs}} ]; return; } =head2 _current() Returns the tuple object currently under the cursor. =cut sub _current { my ($self) = @_; $self->_fetch_certificates; return $self->{certs}->[$self->{index}]; } =end internals =head2 count Returns the total number of entries in this cursor, independently of the number of times L has already been called. =cut sub count { my ($self) = @_; return @{$self->{certs}} if $self->{certs}; # No-camel optimization, isn't it? No! learning test of # DBIx::Class! Syntagm found in # L. my $count = $self->{cursor}->search ({}, { select => [ { count => { distinct => 'me.certid' } } ], as => [ 'count' ] }); my $retval = $count->next->get_column("count"); $count->next; # Reach the end of records, close the statment # subjacent handle, and so remove an useless warning. return $retval; } =head2 has_more Returns true if, and only if, the cursor has still some results to propose. All methods hereafter have an undef behavior when I returns false. =cut sub has_more { defined(shift->_current) } =head2 next Makes the cursor advance one position. =cut sub next { my ($self) = @_; $self->{index}++; return; } =head2 certificate Returns the certificate currently under the cursor, in a L object. =cut sub certificate { App::CamelPKI::Certificate->parse(shift->_current->der) } =head2 infos Returns a table of structures and contents simliar to the table %infos passed to L at the time of the certiticate insertion in database. In a scalar context, returns a reference on a hash which contains references on lists; In a list context, returns this same hash "flat" (a list alternating scalar keys and values which are references on lists). The order of the %info keys, and the order of values contained in when more than on key provided, is B preserved. =cut sub infos { my ($self) = @_; $self->_fetch_infos; return wantarray ? %{$self->{infos}->[$self->{index}]} : $self->{infos}->[$self->{index}]; } =head2 revocation_time =head2 revocation_reason =head2 compromise_time =head2 hold_instruction Returns the canonical form of the revocation informations corresponding to the certificate present under the cursor at that time: time stamps are in "zulu" format; I returns a symbolic character chains among the one listed in RFC3280 section 5.3.1; I returns an OID in decimal notation separated by dots (for example C<1.2.840.10040.2.1>). =cut sub revocation_time { shift->_current->revocation_time } sub revocation_reason { shift->_current->revocation_reason } sub compromise_time { shift->_current->compromise_time } sub hold_instruction { shift->_current->hold_instruction } =begin internals =head1 INTERNAL METHODS =cut package App::CamelPKI::CADB; =head2 DESTROY Called when the object is to be destroyed; disconnect the underlying database to get rid off stupid warnings (see discussions on L). =cut sub DESTROY { local $@; my ($self) = @_; return if ! defined $self->{dbix}; my $storage = $self->{dbix}->storage; return if ! defined $storage; $storage->disconnect; } =head1 INTERNAL CLASS METHODS =head2 _schema_class Returns the name of the object class containing the schema declaration, L. =cut sub _schema_class { my ($self) = @_; $self = ref($self) if ref($self); return "${self}::_Schema"; } =head2 _db_file($homedir) Returns the name of the file which contains the SQLite database. =cut sub _db_file { my ($class, $dir) = @_; return catfile($dir, "ca.db"); } =head2 _dsn($homedir) Returns the DSN (connection string for L) usefull to connect to the AC database present in $homedir. =cut sub _dsn { my ($class, $dir) = @_; return "dbi:SQLite:" . $class->_db_file($dir); } =head2 _connect($homedir) =head2 _connect($homedir) Creates and returns a L connection to the database present in $homedir, pursuant to values returned by class methods used precedently. =cut sub _connect { my ($class, $dir) = @_; $class->_schema_class->connect($class->_dsn($dir), undef, undef, { RaiseError => 1, PrintError => 0, AutoCommit => 1 }); } =head2 App::CamelPKI::CADB::_Schema::CertInfo An instance of this class represents a line in the auxilliary table C, each tuple (C, K, V) of this table modelise the relation between "the C certificate has been added with the V value for the K key in the %info parameters at the time L was invoked". Note that the API schema of L and L allow for multi-valued keys in %info. =cut package App::CamelPKI::CADB::_Schema::CertInfo; use base qw/DBIx::Class/; __PACKAGE__->load_components(qw(Core)); __PACKAGE__->table("cert_info"); __PACKAGE__->add_columns (certid => { data_type => "integer", is_nullable => 0, }, key => { data_type => "text", is_nullable => 0 }, val => { data_type => "text", is_nullable => 0 }, ); =head2 App::CamelPKI::CADB::_Schema::Certificate An instance of this class represents a line in the C table, which in its turn represent a certifice (what a surprise!) and its revocation status informations. =cut package App::CamelPKI::CADB::_Schema::Certificate; use base qw/DBIx::Class/; __PACKAGE__->load_components(qw(PK::Auto Core)); __PACKAGE__->table("certificate"); __PACKAGE__->add_columns # An unique number of certificate, which must *never be* # visible outside of the present class. (certid => { data_type => "integer", is_nullable => 0, auto_increment => 1, }, # The certificate in the form of a DER encoded blob. der => { data_type => "blob", is_nullable => 0, }, # La date de révocation, au format "zulu" à 4 chiffres pour # l'année; ou la date de retour en grâce dans un cas d'une # révocation temporaire abandonnée. Initialement NULL au # moment de la certification. # The revocation date, in "zulu" format with 4 digits for the year # date; or the return in grace date in case of a canceled temporary # revocation. Initially NULL at the time of the certification. revocation_time => { data_type => "text", is_nullable => 1, }, # The reason for revocation, in the form of a character string # (for example: "cessationOfOperation") revocation_reason => { data_type => "text", is_nullable => 1, }, # The date of compromission, in "zulu" format. compromise_time => { data_type => "text", is_nullable => 1, }, # The "hold instruction", in the form of an decimal OID notation # separated by dots. hold_instruction => { data_type => "text", is_nullable => 1, }, # Fields that follow are de-normalisations on the "der" field, # to allow searchs. # The serial number, on a hexadecimal textual form, used by # Crypt::OpenSSL::CA (ie "0x1234deadbeef"). serial => {data_type => "text", is_nullable => 0, }, # Dates of validity for the certificate, in "zulu" format with # 4 digit for the year date. not_before => { data_type => "text", is_nullable => 0 }, not_after => { data_type => "text", is_nullable => 0 }, ); __PACKAGE__->set_primary_key("certid"); __PACKAGE__->has_many("infos", "App::CamelPKI::CADB::_Schema::CertInfo", "certid"); =head2 App::CamelPKI::CADB::_Schema::Sequence This class represents the "sequences" table, which contains one line for each sequence created with L or L. =cut package App::CamelPKI::CADB::_Schema::Sequence; use base qw/DBIx::Class/; __PACKAGE__->load_components(qw(Core)); __PACKAGE__->table("sequences"); __PACKAGE__->add_columns # The name of the sequence, in minor case (name => { data_type => "text", is_nullable => 0, }, # The current sequence number val => { data_type => "integer", is_nullable => 0, }); __PACKAGE__->set_primary_key("name"); =head2 App::CamelPKI::CADB::_Schema This class represents the whole database schema. Instances of this class (created by L) represent a connection to a concrete database. =cut package App::CamelPKI::CADB::_Schema; use base qw/DBIx::Class::Schema/; __PACKAGE__->load_classes(qw(Certificate CertInfo Sequence)); =head3 throw_exception Overload of the parent class to throw L. =cut sub throw_exception { my $self = shift; throw App::CamelPKI::Error::Database(join(" ", @_)); } =head2 App::CamelPKI::CADB::_Logger Auxilliary class to observe SQL requests, as suggested in L. Used by L to honor the setting done by L. =cut package App::CamelPKI::CADB::_Logger; sub new { my ($class, $debugfunc) = @_; bless { debugfunc => $debugfunc }, $class; } sub txn_begin {} sub txn_commit {} sub query_start {} sub query_end { my ($self, @params) = @_; $self->{debugfunc}->(@params); } require My::Tests::Below unless caller; 1; __END__ =head1 TEST SUITE =cut use Test::More no_plan => 1; use Test::Group; use File::Spec::Functions qw(catfile catdir); use IO::File; use Fatal qw(mkdir); use File::Slurp qw(read_file); use App::CamelPKI::Error; use App::CamelPKI::Sys qw(fork_and_do); use App::CamelPKI::Test qw(%test_self_signed_certs %test_entity_certs); use App::CamelPKI::Certificate; use Crypt::OpenSSL::CA; test "learning: storing with real pieces of NUL characters " . "inside" => sub { # Let's prepare a dummy schema... { package Bogus::Schema::Beware; use base qw/DBIx::Class/; __PACKAGE__->load_components(qw(Core)); __PACKAGE__->table("beware"); __PACKAGE__->add_columns("blob" => { data_type => "blob" }); package Bogus::Schema; use base qw/DBIx::Class::Schema/; __PACKAGE__->load_classes(qw(Beware)); } # This comment only serves to help Emacs out... my $testdsn = "dbi:SQLite:" . catdir(My::Tests::Below->tempdir, "testnul.db"); Bogus::Schema->connect($testdsn)->deploy(); my $bogon = "zoinx\0" x 2; my $schema = Bogus::Schema->connect($testdsn); $schema->resultset('Beware')->create ({ blob => $bogon }); undef $schema; $schema = Bogus::Schema->connect($testdsn); is($schema->resultset('Beware')->first->blob, $bogon, "bogon has persisted as planned"); my $resultset = $schema->resultset('Beware') ->search({blob => $bogon}); is($resultset->count, 1, "searching per blob 1/2"); $resultset = $schema->resultset('Beware') ->search({blob => $bogon . "\0"}); is($resultset->count, 0, "searching per blob 2/2"); }; my $cert = App::CamelPKI::Certificate->parse ($test_self_signed_certs{"rsa1024"}); =head2 change_db_dir() Change the value of $testdir and recreates an empty database therein. =cut my $testdir; { my $unique = 0; sub change_db_dir { $unique++; $testdir = catdir(My::Tests::Below->tempdir, "testdb$unique"); } } change_db_dir(); test "initialisation of the DB" => sub { local $SIG{__WARN__} = sub { my $warn = shift; if ($warn !~ /closing dbh with active statement handles/){ warn shift; fail } }; # Making warnings # fatal, such as the usual suspect "Issuing rollback() ..." my $db = App::CamelPKI::CADB->load($testdir); ok($db->isa("App::CamelPKI::CADB")); is($db->search()->count(), 0); }; =head2 open_db() Open a connection to the database for testing purposes. SQL requests are recorded in the @queries global variable, so that tests are able to inspect the requests they caused to be made. If the $debug_queries variable is set to a true value, SQL requests will also be printed to STDERR. =cut our @queries; our $debug_queries; App::CamelPKI::CADB->debug_statements(sub { my ($sql, @bind_values) = @_; push(@queries, $sql); map { $_ = "" if m/[\000-\010]/ } @bind_values; diag join(" / ", $sql, @bind_values) . "\n" if $debug_queries; }); sub open_db { my $cadb = load App::CamelPKI::CADB($testdir); return $cadb; } test "->add()" => sub { my $cadb = open_db; $cadb->add($cert, template => "foobar"); try { $cadb->add($cert, zoinx => ["deux", "mille" ]); fail("inserting doubled bloom prohibited"); } catch App::CamelPKI::Error::Database with { pass; }; $cadb = open_db; $cadb->add($cert, template => "foobar"); # Works because the # transaction has been rollbacked $cadb->add(App::CamelPKI::Certificate->parse ($test_entity_certs{"rsa1024"}), foo => "bar", zoinx => ["is", "tan" ], ); $cadb->commit; is($cadb->search()->count(), 2, "certificates in base"); }; test "->search() in list context" => sub { my $cadb = open_db; my @certs = $cadb->search(-initially_valid_at => "20010101020400Z"); is(scalar(@certs), 0); @certs = $cadb->search(-initially_valid_at => "now"); is(scalar(@certs), 2, "all certificates"); grep { ok($_->isa("App::CamelPKI::Certificate")) } @certs; @certs = $cadb->search(-certificate => $cert); is(scalar(@certs), 1); ok($certs[0]->equals($cert)); }; test "->search() with a cursor" => sub { my $cadb = open_db; my $cursor = $cadb->search(-revoked => undef); is($cursor->count, 2); ok($cursor->has_more); ok($cursor->certificate->isa("App::CamelPKI::Certificate")); $cursor->next; ok($cursor->has_more); ok($cursor->certificate->isa("App::CamelPKI::Certificate")); $cursor->next; ok(! $cursor->has_more); $cursor = $cadb->search(-revoked => undef); isnt($cursor->infos, undef, "consulting ->infos available " . "even if we don't look for them"); $cursor = $cadb->search(template => "foobar", -revoked => 0); ok($cursor->has_more); is($cursor->infos->{template}->[0], "foobar"); ok($cursor->has_more, "the cursor did not move"); $cursor->next; ok(! $cursor->has_more); $cursor = $cadb->search(template => "foobar", -revoked => 1); ok(! $cursor->has_more); is($cursor->count, 0, 'Filter "and" which exclude all'); # This one is tricky: the search matches for two reasons (zoinx => # "is" and zoinx => "tan"), but we want only one response back. $cursor = $cadb->search(zoinx => undef); is($cursor->count, 1); is_deeply([ sort @{$cursor->infos->{zoinx}}], [qw(is tan)]) or warn Data::Dumper::Dumper(scalar $cursor->infos); }; test "REGRESSION: searching with multiple nominatives keys" => sub { my $cadb = open_db; my @certs = $cadb->search(foo => "bar", zoinx => "is"); is(scalar(@certs), 1); @certs = $cadb->search(foo => "bar", zoinx => "is", zoinx => "tan"); is(scalar(@certs), 1); }; test "->revoke()" => sub { my $cadb = open_db; $cadb->revoke($cert, -revocation_reason => "keyCompromise", -compromise_time => "now"); $cadb->commit(); is($cadb->search()->count(), 1, "only valid certificates by default"); is($cadb->search(-revoked => undef)->count(), 2, "all certificates"); is($cadb->search(-revoked => 1)->revocation_reason, "keyCompromise"); like($cadb->search(-revoked => 1)->compromise_time, qr/^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}Z$/, "the compromise time has been canonicalized"); $cadb->revoke($cert, -revocation_reason => "removeFromCRL"); $cadb->commit(); is($cadb->search()->count(), 2, "certificate redemption"); }; test "->next_serial() et ->max_serial()" => sub { my $cadb = open_db; my @serialz = map { $cadb->next_serial("corn") } (1..10); grep { cmp_ok($serialz[$_], ">=", 2) } (0..$#serialz); grep { cmp_ok($serialz[$_ - 1], "<", $serialz[$_]) } (1..$#serialz); my $maxserial = $cadb->max_serial("corn"); is($cadb->max_serial("corn"), $maxserial, "->max_serial is idempotent"); grep { cmp_ok($serialz[$_], "<=", $maxserial) } (0..$#serialz); cmp_ok($cadb->next_serial("corn"), ">", $maxserial); }; test "real unicity for ->next_serial() and ->max_serial()" => sub { my $numprocs = 5; my $numincs = 10; my $numcommits = 10; my $resultsfile = catfile($testdir, "serialz.txt"); my $fd = new IO::File($resultsfile, ">"); $fd->autoflush(1); my @pids = map { fork_and_do { my $base = open_db; COMMIT: for my $i (1..$numcommits) { my $done = try { for my $j (1..$numincs) { $fd->print($base->next_serial("zoinx") . "\n"); } $base->commit; 1; } catch Error with { my $E = shift; die($E) unless ($E =~ m/database is locked/i); select(undef, undef, undef, rand); $base = open_db; 0; }; # If we got a serial with ->next_serial, the test as now # written makes it mandatory that we succeed to commit # later. It's not strictly needed if the caller knows how to # do a two-phase commit, but we err on the safe side. redo COMMIT if ! $done; } } } (1..$numprocs); while(@pids) { waitpid(shift(@pids), 0); } my @lines = read_file($resultsfile); is(scalar(@lines), $numprocs * $numincs * $numcommits, "right number of lines in $resultsfile"); my %serialz = map { $_ => 1 } (@lines); is(scalar(keys %serialz), scalar(@lines), "no collision in $resultsfile"); }; change_db_dir(); use App::CamelPKI::Test qw(%test_public_keys %test_keys_plaintext); test "->search() and left-join request optimization" => sub { my $cadb = open_db; my $pubkey = Crypt::OpenSSL::CA::PublicKey ->parse_RSA($test_public_keys{rsa1024}); my $privkey = Crypt::OpenSSL::CA::PrivateKey ->parse($test_keys_plaintext{rsa1024}); foreach my $i (1..100) { my $cert_to_be = Crypt::OpenSSL::CA::X509->new($pubkey); $cert_to_be->set_notBefore("20070101000000Z"); $cert_to_be->set_notAfter("20570101000000Z"); $cert_to_be->set_serial(sprintf("0x%x", $i)); my $cert = App::CamelPKI::Certificate->parse ($cert_to_be->sign($privkey, "sha256")); $cadb->add($cert, foo => "bar", baz => "quux"); } $cadb->commit(); @queries = (); my $cursor = $cadb->search(-revoked => undef); foreach my $i (1..100) { ok($cursor->has_more); is($cursor->infos->{foo}->[0], "bar"); is($cursor->infos->{baz}->[0], "quux"); $cursor->next; } ok(! $cursor->has_more); cmp_ok(scalar(@queries), "<", 10, "the number of requests is sub-linear " . "wrt the number of fetched certificates"); }; change_db_dir(); test "REGRESSION: searching by infos must not mask some of them" => sub { my $cadb = open_db; my $cert = App::CamelPKI::Certificate->parse ($test_self_signed_certs{"rsa2048"}); $cadb->add($cert, foo => "bar", baz => [ "quux", "bloggs" ]); # Witness Experiency: my $cursor = $cadb->search(); is($cursor->count, 1); my $infos = $cursor->infos; is($infos->{foo}->[0], "bar") or warn Data::Dumper::Dumper($infos); is_deeply([sort @{$infos->{baz}}], [qw(bloggs quux)]); # Experiency test: $cursor = $cadb->search(foo => "bar"); is($cursor->count, 1); is_deeply(scalar($cursor->infos), $infos) or warn Data::Dumper::Dumper(scalar($cursor->infos)); }; change_db_dir(); test "synopsis" => sub { my $code = My::Tests::Below->pod_code_snippet("synopsis"); $code =~ s/\bmy /our /g; my $dir = $testdir; my $certificate = $cert; eval "package Synopsis; $code"; fail($@) if $@; cmp_ok($Synopsis::serial, ">=", 2); ok($Synopsis::cadb->isa("App::CamelPKI::CADB")); ok($Synopsis::cursor->isa("App::CamelPKI::CADB::Cursor")); is($Synopsis::cert->serialize(), $certificate->serialize()); is($Synopsis::infos{foo}->[0], "bar"); is_deeply([sort @{$Synopsis::infos{baz}}], [qw(bloggs quux)]); like($Synopsis::revocation_time, qr/^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}Z$/, "revocation time looks ok"); is($Synopsis::revocation_reason, "keyCompromise"); is($Synopsis::compromise_time, "20070313104800Z"); }; =end internals =cut