Annotation of palm/Palm-Keyring/lib/Palm/Keyring.pm, Revision 1.27
1.14 andrew 1: package Palm::Keyring;
1.27 ! andrew 2: # $RedRiver: Keyring.pm,v 1.26 2007/02/06 02:58:50 andrew Exp $
! 3: ########################################################################
! 4: # Keyring.pm *** Perl class for Keyring for Palm OS databases.
! 5: #
! 6: # This started as Memo.pm, I just made it work for Keyring.
1.1 andrew 7: #
1.27 ! andrew 8: # 2006.01.26 #*#*# andrew fresh <andrew@cpan.org>
! 9: ########################################################################
! 10: # Copyright (C) 2006, 2007 by Andrew Fresh
1.1 andrew 11: #
1.27 ! andrew 12: # This program is free software; you can redistribute it and/or modify
! 13: # it under the same terms as Perl itself.
! 14: ########################################################################
1.1 andrew 15: use strict;
1.14 andrew 16: use warnings;
1.27 ! andrew 17:
1.14 andrew 18: use Carp;
19:
20: use base qw/ Palm::StdAppInfo /;
1.1 andrew 21:
22: use Digest::MD5 qw(md5);
1.2 andrew 23: use Crypt::DES;
1.14 andrew 24:
1.24 andrew 25: my $ENCRYPT = 1;
26: my $DECRYPT = 0;
27: my $MD5_CBLOCK = 64;
28: my $kSalt_Size = 4;
29: my $EMPTY = q{};
30: my $SPACE = q{ };
31: my $NULL = chr 0;
1.14 andrew 32:
1.26 andrew 33: our $VERSION = 0.94;
1.1 andrew 34:
1.14 andrew 35: sub new {
36: my $classname = shift;
37: my $pass = shift;
1.1 andrew 38:
1.14 andrew 39: # Create a generic PDB. No need to rebless it, though.
40: my $self = $classname->SUPER::new(@_);
1.1 andrew 41:
1.14 andrew 42: $self->{'name'} = 'Keys-Gtkr'; # Default
43: $self->{'creator'} = 'Gtkr';
44: $self->{'type'} = 'Gkyr';
45:
46: # The PDB is not a resource database by
47: # default, but it's worth emphasizing,
48: # since MemoDB is explicitly not a PRC.
49: $self->{'attributes'}{'resource'} = 0;
1.1 andrew 50:
1.14 andrew 51: # Initialize the AppInfo block
52: $self->{'appinfo'} = {};
1.1 andrew 53:
1.14 andrew 54: # Add the standard AppInfo block stuff
55: Palm::StdAppInfo::seed_StdAppInfo( $self->{'appinfo'} );
1.1 andrew 56:
1.14 andrew 57: # Set the version
58: $self->{'version'} = 4;
1.1 andrew 59:
1.14 andrew 60: if ( defined $pass ) {
1.16 andrew 61: $self->Password($pass);
1.14 andrew 62: }
1.1 andrew 63:
1.14 andrew 64: return $self;
65: }
1.1 andrew 66:
1.14 andrew 67: sub import {
68: Palm::PDB::RegisterPDBHandlers( __PACKAGE__, [ 'Gtkr', 'Gkyr' ], );
69: return 1;
70: }
1.1 andrew 71:
1.16 andrew 72: sub ParseRecord {
1.14 andrew 73: my $self = shift;
74:
1.16 andrew 75: my $rec = $self->SUPER::ParseRecord(@_);
76:
77: # skip the 0 record that holds the password
78: return $rec if ! exists $self->{'records'};
79: return $rec if ! exists $rec->{'data'};
1.14 andrew 80:
1.16 andrew 81: my ( $name, $encrypted ) = split /$NULL/xm, $rec->{'data'}, 2;
1.1 andrew 82:
1.16 andrew 83: return $rec if ! $encrypted;
1.19 andrew 84: delete $rec->{'data'};
85: $rec->{'name'} = $name;
1.16 andrew 86: $rec->{'encrypted'} = $encrypted;
1.12 andrew 87:
1.16 andrew 88: return $rec;
1.14 andrew 89: }
1.11 andrew 90:
1.16 andrew 91: sub PackRecord {
92: my $self = shift;
93: my $rec = shift;
94:
1.23 andrew 95: if ($rec->{'encrypted'}) {
1.22 andrew 96: if (! defined $rec->{'name'}) {
97: $rec->{'name'} = $EMPTY;
98: }
1.19 andrew 99: $rec->{'data'} = join $NULL, $rec->{'name'}, $rec->{'encrypted'};
100: delete $rec->{'name'};
1.16 andrew 101: delete $rec->{'encrypted'};
102: }
1.1 andrew 103:
1.16 andrew 104: return $self->SUPER::PackRecord($rec, @_);
1.14 andrew 105: }
1.1 andrew 106:
1.14 andrew 107: sub Encrypt {
108: my $self = shift;
1.16 andrew 109: my $rec = shift;
110: my $data = shift;
111: my $pass = shift || $self->{'password'};
112:
113: if ( ! $pass) {
114: croak("'password' not set!\n");
115: }
116:
117: if ( ! $rec) {
118: croak("Needed parameter 'record' not passed!\n");
119: }
1.14 andrew 120:
1.16 andrew 121: if ( ! $data) {
122: croak("Needed parameter 'data' not passed!\n");
1.14 andrew 123: }
124:
1.16 andrew 125: if ( ! $self->Password($pass)) {
126: croak("Incorrect Password!\n");
127: }
1.14 andrew 128:
1.22 andrew 129: $self->{'digest'} ||= _calc_keys( $pass );
1.14 andrew 130:
1.16 andrew 131: $data->{'account'} ||= $EMPTY;
132: $data->{'password'} ||= $EMPTY;
133: $data->{'notes'} ||= $EMPTY;
1.1 andrew 134:
1.22 andrew 135: my $changed = 0;
136: my $need_newdate = 0;
137: my $acct = {};
138: if ($rec->{'encrypted'}) {
139: $acct = $self->Decrypt($rec, $pass);
140: foreach my $key (keys %{ $data }) {
141: next if $key eq 'lastchange';
142: if ($data->{$key} ne $acct->{$key}) {
143: $changed = 1;
144: last;
145: }
146: }
147: if ( exists $data->{'lastchange'} && exists $acct->{'lastchange'} && (
148: $data->{'lastchange'}->{day} != $acct->{'lastchange'}->{day} ||
149: $data->{'lastchange'}->{month} != $acct->{'lastchange'}->{month} ||
150: $data->{'lastchange'}->{year} != $acct->{'lastchange'}->{year}
151: )) {
152: $changed = 1;
153: $need_newdate = 0;
154: } else {
155: $need_newdate = 1;
156: }
157:
158: } else {
159: $changed = 1;
160: }
161:
162: # no need to re-encrypt if it has not changed.
163: return 1 if ! $changed;
164:
1.21 andrew 165: my ($day, $month, $year);
166:
1.22 andrew 167: if ($data->{'lastchange'} && ! $need_newdate ) {
168: $day = $data->{'lastchange'}->{'day'} || 1;
169: $month = $data->{'lastchange'}->{'month'} || 0;
170: $year = $data->{'lastchange'}->{'year'} || 0;
171:
172: # XXX Need to actually validate the above information somehow
173: if ($year >= 1900) {
174: $year -= 1900;
175: }
176: } else {
177: $need_newdate = 1;
178: }
179:
180: if ($need_newdate) {
1.21 andrew 181: ($day, $month, $year) = (localtime)[3,4,5];
182: }
1.22 andrew 183: $year -= 4;
184: $month++;
185:
1.19 andrew 186:
187: my $p = $day | ($month << 5) | ($year << 9);
188: my $packeddate = pack 'n', $p;
189:
1.16 andrew 190: my $plaintext = join $NULL,
1.19 andrew 191: $data->{'account'}, $data->{'password'}, $data->{'notes'}, $packeddate;
1.1 andrew 192:
1.16 andrew 193: my $encrypted = _crypt3des( $plaintext, $self->{'digest'}, $ENCRYPT );
1.11 andrew 194:
1.16 andrew 195: return if ! $encrypted;
1.1 andrew 196:
1.19 andrew 197: $rec->{'attributes'}{'Dirty'} = 1;
198: $rec->{'attributes'}{'dirty'} = 1;
199: $rec->{'name'} ||= $data->{'name'};
1.16 andrew 200: $rec->{'encrypted'} = $encrypted;
1.19 andrew 201:
1.14 andrew 202: return 1;
203: }
1.1 andrew 204:
1.14 andrew 205: sub Decrypt {
206: my $self = shift;
1.16 andrew 207: my $rec = shift;
208: my $pass = shift || $self->{'password'};
209:
210: if ( ! $pass) {
211: croak("'password' not set!\n");
212: }
213:
214: if ( ! $rec) {
1.19 andrew 215: croak("Needed parameter 'record' not passed!\n");
1.16 andrew 216: }
1.14 andrew 217:
1.16 andrew 218: if ( ! $self->Password($pass)) {
219: croak("Invalid Password!\n");
1.14 andrew 220: }
221:
1.16 andrew 222: if ( ! $rec->{'encrypted'} ) {
223: croak("No encrypted content!");
224: }
1.14 andrew 225:
1.16 andrew 226: $self->{'digest'} ||= _calc_keys( $pass );
1.14 andrew 227:
1.16 andrew 228: my $decrypted =
229: _crypt3des( $rec->{'encrypted'}, $self->{'digest'}, $DECRYPT );
1.19 andrew 230: my ( $account, $password, $notes, $packeddate ) = split /$NULL/xm,
1.16 andrew 231: $decrypted, 4;
1.14 andrew 232:
1.19 andrew 233: my %Modified;
234: if ($packeddate) {
235: my $u = unpack 'n', $packeddate;
236: my $year = (($u & 0xFE00) >> 9) + 4; # since 1900
237: my $month = (($u & 0x01E0) >> 5) - 1; # 0-11
238: my $day = (($u & 0x001F) >> 0); # 1-31
239:
240: %Modified = (
241: year => $year,
242: month => $month || 0,
243: day => $day || 1,
244: );
245: }
246:
1.16 andrew 247: return {
1.20 andrew 248: name => $rec->{'name'},
249: account => $account,
250: password => $password,
251: notes => $notes,
252: lastchange => \%Modified,
1.16 andrew 253: };
254: }
1.14 andrew 255:
1.16 andrew 256: sub Password {
257: my $self = shift;
1.24 andrew 258: my $pass = shift;
1.16 andrew 259: my $new_pass = shift;
1.14 andrew 260:
1.24 andrew 261: if (! $pass) {
262: delete $self->{password};
263: return 1;
264: }
265:
1.16 andrew 266: if (! exists $self->{'records'}) {
267: # Give the PDB the first record that will hold the encrypted password
268: $self->{'records'} = [ $self->new_Record ];
269:
270: return $self->_password_update($pass);
271: }
272:
273: if ($new_pass) {
274: my @accts = ();
275: foreach my $i (0..$#{ $self->{'records'} }) {
276: if ($i == 0) {
277: push @accts, undef;
278: next;
279: }
280: my $acct = $self->Decrypt($self->{'records'}->[$i], $pass);
281: if ( ! $acct ) {
1.19 andrew 282: croak("Couldn't decrypt $self->{'records'}->[$i]->{'name'}");
1.16 andrew 283: }
284: push @accts, $acct;
285: }
1.14 andrew 286:
1.16 andrew 287: if ( ! $self->_password_update($new_pass)) {
288: croak("Couldn't set new password!");
289: }
290: $pass = $new_pass;
1.1 andrew 291:
1.16 andrew 292: foreach my $i (0..$#accts) {
293: next if $i == 0;
1.22 andrew 294: delete $self->{'records'}->[$i]->{'encrypted'};
1.16 andrew 295: $self->Encrypt($self->{'records'}->[$i], $accts[$i], $pass);
296: }
1.14 andrew 297: }
1.1 andrew 298:
1.16 andrew 299: return $self->_password_verify($pass);
1.1 andrew 300: }
301:
1.14 andrew 302: sub _calc_keys {
303: my $pass = shift;
304: if (! defined $pass) { croak('No password defined!'); };
305:
306: my $digest = md5($pass);
307:
308: my ( $key1, $key2 ) = unpack 'a8a8', $digest;
309:
310: #--------------------------------------------------
311: # print "key1: $key1: ", length $key1, "\n";
312: # print "key2: $key2: ", length $key2, "\n";
313: #--------------------------------------------------
314:
315: $digest = unpack 'H*', $key1 . $key2 . $key1;
316:
317: #--------------------------------------------------
318: # print "Digest: ", $digest, "\n";
319: # print length $digest, "\n";
320: #--------------------------------------------------
321:
322: return $digest;
1.3 andrew 323: }
324:
1.16 andrew 325: sub _password_verify {
1.14 andrew 326: my $self = shift;
327: my $pass = shift;
328:
329: if (! $pass) { croak('No password specified!'); };
1.11 andrew 330:
1.16 andrew 331: if (defined $self->{'password'} && $pass eq $self->{'password'}) {
332: # already verified this password
333: return 1;
334: }
335:
1.14 andrew 336: # AFAIK the thing we use to test the password is
337: # always in the first entry
338: my $data = $self->{'records'}->[0]->{'data'};
1.11 andrew 339:
1.14 andrew 340: #die "No encrypted password in file!" unless defined $data;
1.16 andrew 341: if ( ! defined $data) { return; };
1.11 andrew 342:
1.14 andrew 343: $data =~ s/$NULL$//xm;
1.11 andrew 344:
1.14 andrew 345: my $salt = substr $data, 0, $kSalt_Size;
1.11 andrew 346:
1.14 andrew 347: my $msg = $salt . $pass;
1.11 andrew 348:
1.14 andrew 349: $msg .= "\0" x ( $MD5_CBLOCK - length $msg );
1.11 andrew 350:
1.14 andrew 351: my $digest = md5($msg);
1.11 andrew 352:
1.14 andrew 353: if ( $data eq $salt . $digest ) {
1.11 andrew 354:
1.14 andrew 355: # May as well generate the keys we need now, since we know the password is right
356: $self->{'digest'} = _calc_keys($pass);
357: if ( $self->{'digest'} ) {
358: $self->{'password'} = $pass;
359: return 1;
360: }
361: }
362: return;
1.6 andrew 363: }
364:
1.16 andrew 365: sub _password_update {
1.14 andrew 366:
367: # It is very important to Encrypt after calling this
368: # (Although it is generally only called by Encrypt)
369: # because otherwise the data will be out of sync with the
370: # password, and that would suck!
371: my $self = shift;
372: my $pass = shift;
373:
1.16 andrew 374: if (! defined $pass) { croak('No password specified!'); };
1.14 andrew 375:
376: my $salt;
377: for ( 1 .. $kSalt_Size ) {
378: $salt .= chr int rand 255;
379: }
380:
381: my $msg = $salt . $pass;
1.11 andrew 382:
1.14 andrew 383: $msg .= "\0" x ( $MD5_CBLOCK - length $msg );
1.11 andrew 384:
1.14 andrew 385: my $digest = md5($msg);
1.11 andrew 386:
1.14 andrew 387: my $data = $salt . $digest; # . "\0";
1.11 andrew 388:
1.14 andrew 389: # AFAIK the thing we use to test the password is
390: # always in the first entry
391: $self->{'records'}->[0]->{'data'} = $data;
1.11 andrew 392:
1.14 andrew 393: $self->{'password'} = $pass;
394: $self->{'digest'} = _calc_keys( $self->{'password'} );
1.11 andrew 395:
1.14 andrew 396: return 1;
1.1 andrew 397: }
398:
1.14 andrew 399: sub _crypt3des {
400: my ( $plaintext, $passphrase, $flag ) = @_;
401:
402: $passphrase .= $SPACE x ( 16 * 3 );
403: my $cyphertext = $EMPTY;
404:
405: my $size = length $plaintext;
1.11 andrew 406:
1.14 andrew 407: #print "STRING: '$plaintext' - Length: " . (length $plaintext) . "\n";
408:
409: my @C;
410: for ( 0 .. 2 ) {
411: $C[$_] =
412: new Crypt::DES( pack 'H*', ( substr $passphrase, 16 * $_, 16 ));
413: }
414:
415: for ( 0 .. ( ($size) / 8 ) ) {
416: my $pt = substr $plaintext, $_ * 8, 8;
417:
418: #print "PT: '$pt' - Length: " . length($pt) . "\n";
419: if (! length $pt) { next; };
420: if ( (length $pt) < 8 ) {
1.16 andrew 421: if ($flag == $DECRYPT) { croak('record not 8 byte padded'); };
1.14 andrew 422: my $len = 8 - (length $pt);
423:
424: #print "LENGTH: $len\n";
425: #print "Binary: '" . unpack("b*", $pt) . "'\n";
426: $pt .= ($NULL x $len);
427:
428: #print "PT: '$pt' - Length: " . length($pt) . "\n";
429: #print "Binary: '" . unpack("b*", $pt) . "'\n";
430: }
431: if ( $flag == $ENCRYPT ) {
432: $pt = $C[0]->encrypt($pt);
433: $pt = $C[1]->decrypt($pt);
434: $pt = $C[2]->encrypt($pt);
435: }
436: else {
437: $pt = $C[0]->decrypt($pt);
438: $pt = $C[1]->encrypt($pt);
439: $pt = $C[2]->decrypt($pt);
440: }
441:
442: #print "PT: '$pt' - Length: " . length($pt) . "\n";
443: $cyphertext .= $pt;
444: }
445:
446: $cyphertext =~ s/$NULL+$//xm;
1.11 andrew 447:
1.14 andrew 448: #print "CT: '$cyphertext' - Length: " . length($cyphertext) . "\n";
1.11 andrew 449:
1.14 andrew 450: return $cyphertext;
451: }
1.11 andrew 452:
1.14 andrew 453: 1;
454: __END__
1.11 andrew 455:
1.14 andrew 456: =head1 NAME
1.11 andrew 457:
1.14 andrew 458: Palm::Keyring - Handler for Palm Keyring databases.
1.1 andrew 459:
1.14 andrew 460: =head1 DESCRIPTION
1.7 andrew 461:
1.14 andrew 462: The Keyring PDB handler is a helper class for the Palm::PDB package. It
463: parses Keyring for Palm OS databases. See
464: L<http://gnukeyring.sourceforge.net/>.
1.1 andrew 465:
1.14 andrew 466: It has the standard Palm::PDB methods with 2 additional public methods.
467: Decrypt and Encrypt.
1.1 andrew 468:
1.16 andrew 469: It currently supports the v4 Keyring databases. The v5 databases from
470: the pre-release keyring-2.0 are not supported.
471:
472: This module doesn't store the decrypted content. It only keeps it until it
473: returns it to you or encrypts it.
1.1 andrew 474:
1.14 andrew 475: =head1 SYNOPSIS
1.1 andrew 476:
1.16 andrew 477: use Palm::PDB;
478: use Palm::Keyring;
1.17 andrew 479:
480: my $pass = 'password';
1.18 andrew 481: my $file = 'Keys-Gtkr.pdb';
482: my $pdb = new Palm::PDB;
1.16 andrew 483: $pdb->Load($file);
1.17 andrew 484:
485: foreach (0..$#{ $pdb->{'records'} }) {
486: next if $_ = 0; # skip the password record
487: my $rec = $pdb->{'records'}->[$_];
488: my $acct = $pdb->Decrypt($rec, $pass);
1.19 andrew 489: print $rec->{'name'}, ' - ', $acct->{'account'}, "\n";
1.16 andrew 490: }
1.1 andrew 491:
1.14 andrew 492: =head1 SUBROUTINES/METHODS
1.1 andrew 493:
1.14 andrew 494: =head2 new
1.11 andrew 495:
1.16 andrew 496: $pdb = new Palm::Keyring([$password]);
1.11 andrew 497:
1.14 andrew 498: Create a new PDB, initialized with the various Palm::Keyring fields
499: and an empty record list.
1.11 andrew 500:
1.14 andrew 501: Use this method if you're creating a Keyring PDB from scratch otherwise you
1.16 andrew 502: can just use Palm::PDB::new() before calling Load().
1.11 andrew 503:
1.24 andrew 504: If you pass in a password, it will initalize the first record with the encrypted
505: password.
506:
1.16 andrew 507: =head2 Encrypt
1.11 andrew 508:
1.24 andrew 509: $pdb->Encrypt($rec, $acct[, $password]);
1.11 andrew 510:
1.16 andrew 511: Encrypts an account into a record, either with the password previously
512: used, or with a password that is passed.
1.1 andrew 513:
1.24 andrew 514: $rec is a record from $pdb->{'records'} or a new_Record().
1.16 andrew 515: $acct is a hashref in the format below.
1.1 andrew 516:
1.16 andrew 517: my $acct = {
1.20 andrew 518: name => $rec->{'name'},
519: account => $account,
520: password => $password,
521: notes => $notes,
522: lastchange => {
523: year => 107, # years since 1900
524: month => 0, # 0-11, 0 = January, 11 = December
1.21 andrew 525: day => 30, # 1-31, same as localtime
1.20 andrew 526: },
1.16 andrew 527: };
1.7 andrew 528:
1.22 andrew 529: If you have changed anything other than the lastchange, or don't pass in a
1.24 andrew 530: lastchange key, Encrypt() will generate a new lastchange date for you.
1.22 andrew 531:
532: If you pass in a lastchange field that is different than the one in the
533: record, it will honor what you passed in.
534:
1.24 andrew 535: Encrypt() only uses the $acct->{'name'} if there is not already a $rec->{'name'}.
1.22 andrew 536:
1.16 andrew 537: =head2 Decrypt
1.1 andrew 538:
1.16 andrew 539: my $acct = $pdb->Decrypt($rec[, $password]);
1.1 andrew 540:
1.16 andrew 541: Decrypts the record and returns a hashref for the account as described
1.20 andrew 542: under Encrypt().
1.1 andrew 543:
1.16 andrew 544: foreach (0..$#{ $pdb->{'records'}) {
545: next if $_ == 0;
546: my $rec = $pdb->{'records'}->[$_];
547: my $acct = $pdb->Decrypt($rec[, $password]);
548: # do something with $acct
549: }
1.1 andrew 550:
1.16 andrew 551: =head2 Password
1.1 andrew 552:
1.16 andrew 553: $pdb->Password([$password[, $new_password]]);
1.1 andrew 554:
1.16 andrew 555: Either sets the password to be used to crypt, or if you pass $new_password,
556: changes the password on the database.
1.1 andrew 557:
1.16 andrew 558: If you have created a new $pdb, and you didn't set a password when you
559: called new(), you only need to pass one password and it will set that as
560: the password.
1.1 andrew 561:
1.24 andrew 562: If nothing is passed, it forgets the password that it was remembering.
1.1 andrew 563:
1.14 andrew 564: =head1 DEPENDENCIES
1.1 andrew 565:
1.14 andrew 566: Palm::StdAppInfo
1.1 andrew 567:
1.14 andrew 568: Digest::MD5
1.9 andrew 569:
1.14 andrew 570: Crypt::DES
1.4 andrew 571:
1.14 andrew 572: Readonly
1.10 andrew 573:
1.24 andrew 574: =head1 THANKS
575:
576: I would like to thank the helpful Perlmonk shigetsu who gave me some great advice
577: and helped me get my first module posted. L<http://perlmonks.org/?node_id=596998>
578:
579: I would also like to thank
580: Johan Vromans
581: E<lt>jvromans@squirrel.nlE<gt> --
582: L<http://www.squirrel.nl/people/jvromans>.
583: He had his own Palm::KeyRing module that he posted a couple of days before
584: mine was ready and he was kind enough to let me have the namespace as well
585: as giving me some very helpful hints about doing a few things that I was
586: unsure of. He is really great.
587:
1.14 andrew 588: =head1 BUGS AND LIMITATIONS
1.1 andrew 589:
1.14 andrew 590: Please report any bugs or feature requests to
591: C<bug-palm-keyring at rt.cpan.org>, or through the web interface at
592: L<http://rt.cpan.org>. I will be notified, and then you'll automatically be
593: notified of progress on your bug as I make changes.
1.1 andrew 594:
595: =head1 AUTHOR
596:
1.27 ! andrew 597: Andrew Fresh E<lt>andrew@cpan.orgE<gt>
1.1 andrew 598:
1.14 andrew 599: =head1 LICENSE AND COPYRIGHT
600:
601: Copyright 2004, 2005, 2006, 2007 Andrew Fresh, All Rights Reserved.
602:
1.15 andrew 603: This program is free software; you can redistribute it and/or
604: modify it under the same terms as Perl itself.
1.14 andrew 605:
1.1 andrew 606: =head1 SEE ALSO
607:
608: Palm::PDB(3)
609:
610: Palm::StdAppInfo(3)
1.11 andrew 611:
612: The Keyring for Palm OS website:
613: L<http://gnukeyring.sourceforge.net/>
1.24 andrew 614:
615: Johan Vromans also has a wxkeyring app that now uses this module, available
1.27 ! andrew 616: from his website at L<http://www.vromans.org/johan/software/sw_palmkeyring.html>
FreeBSD-CVSweb <freebsd-cvsweb@FreeBSD.org>