Windows Crypt API Interfacing
This article may contain URLs that were valid when originally published, but now link to sites or pages that no longer exist. To maintain the flow of the article, we've left these URLs in the text, but disabled the links.
Windows Crypt API Interfacing
The Windows Crypt API is easy to use, easy to implement, thoroughly tested, and can be used to enhance Visual FoxPro applications in several ways. Craig Kimpel here offers a Visual FoxPro Crypt API Class that uses session keys and private/public key pairs to encrypt/decrypt data in Stream or Block, and to create and verify digital signatures.
BeforeI get to the heart of the Visual FoxPro Crypto Class and sample application that I wrote, I need to visit the basics of the Windows Crypt API. The Original Windows 95 didnt have a cryptographic provider included in its installation. It was installed as part of Internet Explorer. The Windows Crypt API (CAPI) was first introduced as part of IE 3.02. Included with the installation of Internet Explorer 3.02 was the Microsoft Base Cryptographic Provider v1.0 (MBCP). Windows 98 and 2000 incorporate IE, which means that the Microsoft Base Cryptographic Provider v1.0 is installed as part of the base operating system installation. Windows NT 4.0 didnt include it until Service Pack 2. Windows NT 4.0 Service Pack 3 also installed the Microsoft Base Cryptographic Provider v1.0; however, it didnt install the digital signature in the registry, meaning that it wasnt usable until a patch registry file was installed. That patch updated the registries MBCP signature field (More on Digital signatures later).
Crypt API calls are ultimately carried out by a module called a Cryptographic Service Provider (CSP). Bottom line, a CSP is a DLL that conforms to the Windows Crypt API conventions. The built-in Windows Crypt API is a wrapper that resides in Windows core DLLs. The Windows Crypt API fields calls from any program, and its own calls will include a pointer to a CSP where the call will ultimately be processed. The Windows API then strips the CSP pointer, routes the call to the intended CSP for processing, and returns the returned values to the originating program. This is the only interface I know of that resides at a lower level than the operating system core.
The CSP needs to reside lower than the core operating system for two reasons:
- 	Windows core DLLs have to periodically verify that the CSP DLL is an authentic CSP DLL and not a fake wrapper sitting in between the operating system and a real but renamed CSP DLL. A fake wrapper could save all of the passwords passed through it, in a database, then pass the calls onto the real CSP to carry them out as normal. The specifics of when and how a CSP DLL is verified arent published, for obvious reasons.
- 	The U.S. Department of Commerce has restrictions on exporting programs that have an open and/or very complex encryption system imbedded in them. By verifying the Crypt API with the core DLLs, all CSPs must be registered directly with Microsoft, and Microsoft makes the vendor convince the U.S. Department of Commerce that their system is compliant before they incorporate it with the Windows Operating System. The U.S. Department of Commerce does make an exception, though. It allows complex encryption outside of the U.S. for international banking transaction applications. France has an additional restriction. France doesnt allow importation of encryption programs. In a French version of Windows, there wont be any CSPs.
Now that we know what a CSP is and how secure it is, lets see which CSPs are installed on my system. Like just about everything else, that information is stored in the Windows Registry database. I ran Regedit.exe and opened the tree to "MyComputer\HKEY_LOCAL_MACHINE\software\Microsoft\Cryptography\Defaults\Provider" That Registry key contains the Registry keys of all of the installed CSPs. Figure 1 provides a look at my Registry.
A CSP Registry key will contain the DLL file name, a DLL signature (more on signatures later), and the type of CSP. The DLL signature could be stored inside the DLL. However, Windows 2000 is the only system capable of looking up the signature in a DLL file. Consequently, the older versions of Windows must use the registry signature field. You might be thinking that knowing where the Registry signature field is and being able to edit it would make this system insecure. Later in this article, Ill explain digital signatures, and why this isnt the case. With my knowledge, I believe that even with the signatures visibility and edit ability in the Registry database, this security system is very solid.
Directly below the provider key is the "Provider Types" key. The "Provider Types" key contains the default CSP for each CSP type. If a specific CSP isnt specified on a call to the Windows Crypt API, the Windows Crypt API will look there for the provider onto which to pass the call.
The CSPs listed in my registry include the following:
- 	Microsofts Base Cryptographic Provider v 1.0 uses a 40-bit key and comes with IE 3.02, all versions of Windows 98/2000, and NT 4.0 with SP2 or higher.
- 	Microsofts Enhanced Cryptographic Provider v 1.0 uses a 128-bit key and is available for unrestricted installation only in the U.S. and Canadian Windows Environments. The 128-bit version is available for export only if the application is specific to international financial transactions.
- 	Microsofts Strong Cryptographic Provider is compatible with both the Base and Enhanced Cryptographic Providers. It comes with Windows 2000. The difference between the Strong and Enhanced Cryptographic Provider is that the Enhanced CSP can only encrypt data with a 128-bit key, but can decrypt data encrypted with either a 40-bit or a 128-bit key. The Enhanced version is compatible with the base on the read cycle, but not on the write cycle. The Strong CSP added 40-bit key encryption to the functionality of the Enhanced CSP to make the Strong fully compatible with the Base CSP.
- 	The DSS provider is for hashing, data signing, and signature verification. The DSS Diffie-Hellman provider is a superset of DSS that also supports key exchange.
- 	Microsoft Exchange Cryptographic Provider is a 64-bit block encryption CSP thats tied to the Mail API. Im not sure what installed that on my Computer!
Note: Dont let the v1.0 in the name fool you as it fooled me. To find the real version, call the CSPs CryptGetProvParam() function. The CSP name is a unique identifier of the CSP (its the key value of a registry call to look up the other pertinent information). If Microsoft changed the name when they changed the version, it wouldnt be backward compatible with old programs that are looking for the old version in the Registry. Whoops! Someones underwear is hanging out in plain sight of the entire world. No serious criticismIve made a few of those mistakes in my day, too.
We dont have to use one of Microsofts CSPs. There are plenty of CSP vendors who boast that their product is better suited to our needs. Since all CSPs have to be registered with Microsoft, the best source of CSP vendor information is at http://www.microsoft.com/security/tech/cryptoapi/cspdev.asp.
A third option would be to use a CSP that doesnt tie in with the Windows CAPI, and just interface directly with their crypto DLL.
A fourth option would be to design our app to use more than one CSP, without letting the user know exactly which CSPs were using or when were using them.
Bits and keys
My password is ABC123and thats longer than 40 bits. So just what does 40 bits mean? It doesnt matter if my password is NULL or 500 characters long, In a 40-bit CSP, all passwords end up at 40 bits. They become 40 bits after the CSP passes them through a mathematical routine (hashing algorithm) that results in a 40-bit number. In the mathematical world, this 40-bit number is called a hash value. In the crypto world, the hash value of a password is called a key. If its 40 bits long, you have a 40-bit encryption key.
By definition a hash value (key in this case) cannot be worked backwards to produce the originating data (the real password in this case). That statement doesnt mean anything in our specific situationwhen we talk about passwords. This is because the hash value (key) is equivalent to the password, since the hash of the password (and not the password itself) is whats used to encrypt or decrypt the data. However, the inability of a hash value to be worked backwards will become important when we talk about digital signatures later.
There are a few tried-and-true rules that make for the best passwords:
- 	ABC123 is not a good password, although its better than many others.
- 	The best password is a password made up of a truly random sequence of numbers and letters.
- 	The next best password is a pseudo-random number, generated by a random number algorithm, which is as long as or longer than (bitwise) the text were encrypting. Computers generate pseudo-random numbers.
- 	Use words that arent real words.
- 	Mix numbers, letters, and punctuation marks. Do not use any consecutive sequences, such as 123.
- 	Be able to type a password faster than the onlooking eye can read it.
- 	Change it often. The more sensitive the data, the more it should be changed. Sixty days max, every day if needed.
- 	Dont write it downremember it or how to derive it. Dont give it away.
My favorite option is number 3. It looks like its the hardest because we cant possibly remember the number, but turns out to be the easiest.
Usually the CSP has a function that will generate a pseudo-random key for us. To save that hard-to-figure-out key, many CSP vendors work with one or more hardware manufacturers. The hardware manufacturer makes a computer peripheral that might look like a credit-card slot, or a key slot, or something similar. Each user has his or her own credit-card key or key-chain key that has the random number burnt into it. Nobody knows what the number is, and no one has to; users need only the key and the hardware. The CSP then reads the key from that device instead of a typed-in password.
Just how secure is a 40-bit key? In the worst-case scenario, a supercomputer can crack a 40-bit key in under one second just by brute force. Brute force means that each key number is tried in succession until a readable decrypted stream is produced. Just how secure is a 128-bit key? Well, each bit adds a factor of 2 to the time. If 40 bits were to take one second to crack, then 41 bits would take two seconds, 42 bits would take four seconds, and so on.
The worst case at 128 bits calculates to a supercomputer running for about one billion times longer than the earth is estimated to have been in existence (if we believe in the Big Bang Theory), without a hardware failure or a general protection fault. That number is even larger than Bill Gates net worth in pennies. These figures, of course, are worst case. Its always possible to hit the exact number on any given iteration, so it could happen very fast in some cases. If a routine were to check the keys of the 64,000 common words, proper names, nicknames, three- to eight-character sequences based on the QWERTY keyboardand then loop again, suffixing all of the prior with up to four-digit numbers, before moving onto the restit might be extremely fast on most passwords that people choose to use, even in a 128-bit CSP. In that case, the number of bits has little meaning. If that routine were to then use only characters found on the normal 102-key keyboard, the time required would also be cut down considerably.
Keys are more than just the hash value of the password. They are actually structures (objects by todays nomenclature) for which an encrypted hash value of the password is one of the fields. The CSP uses its own encryption/decryption to save/retrieve hash values. After all, since sometimes theyre stored in the registry or in an accessible file, they need to be encrypted for shared storage. My guess is that theyre also encrypted using our Windows password, so that another user cant copy the registry key and have useable access to one of our private keys. For obvious reasons, this knowledge isnt published. Another field in the structure will identify the algorithm used to hash the password. The algorithm used to hash the password identifies the encryption method used to encrypt/decrypt the data that uses that key. Windows Base CSP stores its keys in the registry at HKEY_Current_User\Software\Microsoft\Cryptography\UserKeys\<Container Name>. The Container Name for the default key container will be the User IdDof the user that created it. A CSP call can also be used to create key containers of any name I choose.
Stream and block keys
A stream key means that the decrypted data is going to be encrypted one character at a time. The advantage of a stream key is that the encrypted data will take up the same amount of space as the decrypted data. Another advantage is that its slightly faster than the block method. The disadvantage is that the same bit patterns will always encrypt to the same encrypted characters. If some of the patterns are known, it may lead to the cracking of some of the unknown patterns, just like a "Wheel of Fortune" puzzle.
A block key means that the decrypted data is going to be encrypted one block length at a time. The disadvantage is that the encrypted data is going to be up to one block length (a CSP call to CryptGetKeyParam() will let us know the block length) longer than the decrypted data. Another disadvantage is that it takes slightly longer to encrypt and decrypt a block than a stream. The advantage is that an entire block is treated as a single character. The same word appearing in different parts of the text will encrypt to a different bit pattern. This makes it harder to break the encryption by looking at the encrypted data.
A session key means that the same key used to encrypt the data will also decrypt the data. Some authors call these symmetrical keys. The advantage of this method is that its very fast (1000 times faster than the public/private method), and also very simple. A disadvantage is that a person who decrypts the data with the session key can also change the data, encrypt it with the changes, then put it back. The next person to decrypt it doesnt know who made the last change to it. Microsofts CSP can detect a double encryption attempt and will respond with the error NTE_DOUBLE_ENCRYPT.
Public/private key pair
A public/private key pair means that the key used to encrypt the data wont decrypt the data. It takes the other half of the key pair to translate it. Some authors call these asymmetrical keys. The disadvantage is that its more complex and takes 1000 times longer to encrypt/decrypt the message than with a session key. The advantage is that if we keep one of the keys exclusive to only the author, then we can give the other key out to the public for decryption usage. Everybody knows that the author had to originate the text, since a public key cant be used to encrypt a change to the data. A public key that encrypts data requires the private key to decrypt it. Another big advantage is that anyone else can use the authors public key to encrypt a message and send it back to the author, without anyone else who intercepts that message being able to decrypt it. Only the private key will decrypt a message encrypted with a public key.
Public/private keys come in two varieties, exchange and signature. Theyre interchangeable in the API, but exchange is generally used for key exchange purposes, while signature is generally used for signing a hash value of some critical user data.
Public/private keys are so slow that the Crypt API doesnt have a function for using them to encrypt/decrypt just data. They only have functions to encrypt and export other keys through the API.
BLOBs are used to transport keys from one place or user to another, and to or from storage:
- 	PRIVATEBLOBs are exported exchange or signature key pairs that were encrypted with a session key. To import a PRIVATEBLOB and make use of those keys, the BLOB has to be decrypted using the same session key that encrypted it. PrivateBlobs are for storage only; they shouldnt be exchanged with other users or processes, since they house the private key also.
- 	SIMPLEBLOBs are exported session keys that were encrypted with a public exchange or signature key. The corresponding private key is then required to import and decrypt a SIMPLEBLOB back into a usable session key.
- 	PUBLICBLOBs are public exchange or signature keys that were exported without encryption, and can be imported for use by any user or process.
The following scenario demonstrates the proper use of a secure transfer of data from Point A to Point B over a public network: Point A receives Point Bs PUBLICBLOB. Point A then creates a pseudo-random session key and encrypts that session key into a SIMPLEBLOB, using Point Bs public key import from the PUBLICBLOB. Point A sends the SIMPLEBLOB to Point B. Point A then encrypts the data, using the new session key that it just created and sends that encrypted data to Point B. Point B uses its private key to decrypt the SIMPLEBLOB and import the session key that Point A created. Point B then uses that session key to decrypt the data Point A send to Point B. A few simple steps are all it takes to have a private conversation between two parties on a public network.
A signature is nothing more than data run through a hashing algorithm, then encrypted with the owners private key.
A hash has several properties that make it well-suited to digital signatures.
- 	Its simple and very quick.
- 	Its practically as unique as the data that created it.
- 	The Hash Value cant be worked backwards to extract the originating data. If the originating data is encrypted, the publicly available hash doesnt give the encrypted data away to the public.
The hash value of the input data is encrypted using a private key. Any process can decrypt and view the hash value using the proper public key. That process can then run the input data through the same hash algorithm. Lastly, if the processes hash matches the decrypted hash, then the process can be certain that the data has been untouched, since it was last altered by the owning process/user of the private key used to sign the hash.
That leaves only one more topic to cover. In the computer world, we often dont even meet the person or computer from which were extracting information. How can we know that the person or computer sending the data is who it says it is? We use the computer equivalent of a notary public. Its called a certificate. Certificates are issued by a Certificate Authority. A certificate is nothing more than a users public key registered through a CA and encrypted with the CAs private key. If we have the Certificate Authoritys public key, then we can decrypt a certificate, issued by the CA, to get an exchange or signature public key that we know is owned by the process from which were extracting the data. And thats even if that data is stored on a computer owned by someone else. Like keys, certificates are structures (objects), which also contain the identities of their owners by name, by e-mail address, and include a certificate expiration date.
FoxPro Crypto API Class and Demo App
Okay, that was a lot of background, but I hope youll find it worthwhile. Now onto the fun stuff. Figure 2 shows the first screen of my demo application that demonstrates string encryption and decryption. Remember that cryptography is a service running under Windows, not an application running on top of it. Technically, were calling the SPI through the Windows API. It could also be called an SPI, since Windows doesnt do any of the work. The first thing thats needed in a good API is a good error-checking routine. FoxPros ON ERROR wont trap any API errors. Programming without a good error routine is like trying to work on your house before youve built a nice shopit amounts to a lot of wasted time, mess, and frustration. My API calls the standard Windows GetLastError() function. It pops up a window with as much useful information as it can find (the routine attempts to look up the error text in the windows error table). Then the routine saves the most recent number in the classs cnLastApiError property, just in case we want to look at it later. cnLastApiError is a private property, to remove the temptation of setting it from outside the class. We need to call the public method GetLastAPOError() to read it from outside of the class.
Windows error numbers are really structure (objects). The highest two bits (31-32) are the severity level. Bit 30 is the customer code flag. Bit 29 is reserved. Bits 17-28 are the facility, and bits 1-16 are the code. The first time I saw one of these numbers, I thought that Windows had so many errors in it that Microsoft was running out of smaller error numbers. The ones Im used to seeing are severity level 2, which means that bit 32 is set and the number looks like a huge negative twos complement. In the accompanying download file, Ive included the WINERROR.H file, which contains all of the error numbers that Windows will own up to. My code doesnt use WINERROR.H; its there just for reference. The code numbers are also subclassed, but no public subclass documentation exists to my knowledge, and the patterns vary. The crypto error codes start near the bottom at _HRESULT_TYPEDEF_(0x80090001L).
Typically, the most common errors have a severity of 2 (Warning), a facility of 9 (Windows Service Programmable Interface), and a low code number, since SPIs are fairly new.
Note: If we use the WINERROR.H hex number to compare an error code against what FoxPro returns, we need to test it with bitxor() = 0 and not FoxPro Integer = Hex Integer. FoxPro treats a 32-bit API integer as signed and a hex number (0x????) as unsigned. The = operation is not a bit test, so it will see one negative and the other positive and return .F. even when its really .T. Bitxor doesnt care about the sign of a number, so if all bits are the same, a zero is returned. The negative Integer is a twos complement, meaning its signed value wont look anything like its equivalent unsigned value. The eye cant possibly tell that theyre equal, either.
Ive included many of the low-level calls, with the same name as the Crypto API, in case wed like to use them. All start with crypt. Initially I made them private methods, then changed my mind and opened them to the public when I discovered that I was wrapping a one-function call wrapper around several of them. I decided a call directly to them was just as simple. The parameters might be slightly different from the API, so youll have to look at the comments inside of the code to verify what the call is expecting. Some parameters, such as lengths of strings, can be calculated inside of the class, so I removed that parameter from the external call. Not all of them are included, but most of the ones in which a FoxPro programmer would be interested are.
Ive also included several one-stop shopping methods in this class. They perform the most common sequences of function calls, all in one function call.
Ive included bintostr() and strtobin(). I was really excited to make use of some very quick bitshift and bitand routines to convert a number into a character string with the byte significance going from left to right. I was feeling ecstatic until I typed binto in the FoxPro editor and it showed up in blue, meaning that FoxPro already had the exact same command built in. Theirs are called bintoc and ctobin(). My pride wouldnt let me remove them, so theyre still there and in use.
My Demo App has two screens. The second screen demonstrates file encryption and decryption, and is shown in Figure 3.
The demo App:Init() opens with a call to CreateCryptKeys(). If the Crypt API hasnt been used since installation, or our public/private key pairs have been removed from the registry database, this call will create new default keys. This call could also be used to create a new named key container. If the keys already exist, then theyre left as is. Public/private key pairs are the only persistent keys. Session keys must always be created by the App. A call to CryptAcquireContext() creates a handle to the default CSP and default key container.
The Demo App: Destroy() calls CryptReleaseContext(), which frees up the Windows Memory allocated for its use.
The Demo App has 12 separate methods, one for each option. Im a believer in writing several small routines. Its much easier to troubleshoot small chunks than one huge routine. Less trouble means better running code. It also means that a change in one routine is less likely to cause a problem in another routine. Before I learned that lesson, I would have had a 20-page routine, with nested case statements, and Id still be finding, fixing, and creating more problems a year later.
Converting these routines to work on a FoxPro table is a challenge that I left up to the reader. It wouldnt be any fun, and the reader wouldnt learn anything, if I did all of it for us. The string routines should work on a table column, with only a few minor modifications.
Ill convert one for us. Im going to encode a string field with a session password key thats hidden in my encrypted source code. Five function calls are all that are needed:
* Connects to CSP CryptApi.CryptAcquireContext() * Retrieves a Session Stream Handle CryptApi.GetCryptSessionKeyHandle() * Encrypts Entire Table Column Update table set field = CryptApi.EncryptStr(Field, ; KeyHandle, .T.) * Frees up Windows Memory CryptApi.ReleaseCryptKeyHandle() * Frees up Windows Memory CryptApi.CryptReleaseContext()
The first two and last two could be called at App startup and shutdown, leaving only one for the App interior. Its just that easy. By the time I add the error checking and IF statements and change step three to a scan loop to check for errors on each iteration, Ill end up with a solid page of good code.
I had to add a few extra IF statements and functionality to my Demo Apps string routines. I wanted to display their encrypted contents on the screen. Text boxes stop displaying at the first chr(0) encountered in the display string. I use strtran() to convert chr(0) to "CHR(0)" for display purposes. To decrypt the string, I needed the original contents, since I changed them with strtran() for display. I could have used strtran() to convert them back, but it might happen that CHR(0) is a real sequence in the output, so I save the original contents in a form property before the strtran. Just in case the user edits the text in the edit box, I clear that form property when the edit box contents are changed interactively. If the form property is blank, my code uses the edit box value; otherwise my code uses the form property contents. This extra code wouldnt be needed in a normal app.
I havent tried indexing on decryptstr(), but I dont think it would be a good idea. The value of keyhandle might be undefined outside of the app. Also, decrypting for an index would put all of the decrypted values in the index, defeating the purpose of the encryption. Indexing an encrypted value would also be meaningless. If I need an index, then it doesnt make any sense to encrypt that field.
For some great ideas on which fields to encrypt, as well as some encryption techniques, check out Jim Duffys article in the January 1997 issue of FoxTalk ("Add Data Encryption to Your FoxPro Apps"). Jims article uses a public-domain program and not a Windows API, but the bulk of the article applies to this API Class as well.
For help in updating this class API to fit your special needs, see Gary Dewitts excellent article series starting in the September 1997 issue of FoxTalk ("Calling All Windows"). Its a three-part series on API programming that saved me a lot of time.
Microsofts MSDNonline or CD, contains the API call documentation, and some other very helpful technical documentation. Search for the word "crypt" for API function call references or "crypto" for technical documentation. Be careful if youre updating my API. One mistake could result in memory corruption and a lot of GPFs. Heres one that took me over a week to figure out. The MSDN documentation for cryptencrypt() states:
pbdata "This parameter is set to NULL to determine the number of bytes required for the returned data".
This means that setting this parameter to NULL is going to cause the function to return the length of string space required to encrypt my string with this routine, on the next call to cryptencrypt, when this parameter isnt NULL. On the cryptencrypt() call, *pbdata is a pointer to a string and NULL should mean a zero length string. At least thats the way that I read it, and Ive written a lot of NULL strings in my day that have worked perfectly until now. It worked as stated for a block key, which is when I needed to determine the exact length required for the extra string space, before I attempt to encrypt the actual string.
However when I used that same NULL with a stream key, it encrypted the string and wrote over memory bytes starting with the one to which pbdata was pointing (corrupting FoxPros Memory Table), even though the string was NULL. Of course I had no clue what was causing this problem, since the program often functioned for a while after the corruption error. I became frustrated when the addition of block encryption was working perfectly and suddenly my stream code, which had been flawless, was generating GPFs.
Even after I discovered the problem, I wasted a day trying to figure out how to make it work the way the documentation stated, before deciding that the documentation had to be flawed. The confusion lies in two other parameters I have to set up: The length of the string I want to encode, pdwDataLen, and the max length of the string, dwBufLen . I set dwBuflen = 0, stating pbdata is NULL. pdwDataLen must always be the length of the string before encryption, and the cryptencrypt() call with those parameters sets pdwDataLen to the length required to encrypt a string of that length. That method works fine when hKey is set to a block key. A block key encryption that looks at the dwBufLen doesnt write to pbdata. A stream key doesnt look at the contents of dwBufLen, because it assumes that pbdata is pointing to a string of length pdwDataLen bytes.
In my case, pbdata was NULL and dwBufLen was 0. With a stream key, CryptEncrypt() ignored dwBufLen, thereby encrypting and overwriting dwDataLen bytes of FoxPro memory starting at the NULL pointer. The exact same call with a block key in hKey performed no encryption of pbData. This left me with no choice but to pass CryptEncrypt() a string of dwDataLen Bytes pointed to by pbData, just in case I forgot or made a mistake and passed it a stream key. I havent had a GPF since. One sentence in the official documentation of the cryptencrypt() call wasnt quite enough in this case.
Although cryptography relies on a number of concepts and techniques that are new to FoxPro developers, the Windows Crypt API is very easy to use, very easy to implement, thoroughly tested, and can be used to enhance Visual FoxPro applications in several ways. If you have serious security needs, you can use and extend my Visual FoxPro Crypt API Class to encrypt/decrypt data, and to create and verify digital signatures.
Craig Kimpel is just a simple, down-to-earth, peaceful person whos been writing Fox code almost since its inception as FoxBASE. He finds it as fascinating and fulfilling as it was back then. email@example.com.
To find out more about FoxTalk and Pinnacle Publishing, visit their website at http://www.pinpub.com/html/main.isx?sub=57
Note: This is not a Microsoft Corporation website. Microsoft is not responsible for its content.
This article is reproduced from the June 2000 issue of FoxTalk. Copyright 2000, by Pinnacle Publishing, Inc., unless otherwise noted. All rights are reserved. FoxTalk is an independently produced publication of Pinnacle Publishing, Inc. No part of this article may be used or reproduced in any fashion (except in brief quotations used in critical articles and reviews) without prior consent of Pinnacle Publishing, Inc. To contact Pinnacle Publishing, Inc., please call 1-800-493-4867 x4209.