Problem Set 4: Big Numbers and RSA Cryptography

Due Monday, March 28, 11:00 pm.


Instructions: You will do this problem set by modifying the provided source files. You must submit the modified files bignums.sml, rsa.sml, factorization.sml, and induction.txt. As usual, programs that do not compile or compile with warnings will receive an automatic zero.

The goals of this problem set are to explore more complex abstractions and real-world algorithms. We will continue to expect you to maintain the same high standards for specifications and documentation that you learned on PS3.

For this assignment you will be working with a partner; you may use the code of either partner in doing this problem set (but not the code of anyone else).

This assignment consists of three parts. Part A is an abstraction for integers with arbitrary precision. Part B explores the RSA cryptosystem. Finally, the written problem in part C is about induction and asymptotic complexity.

 

Part A: Big Numbers

The native integer type (in SML/NJ) has a size of 31 bits, thus can represent integers between -230 = ~1073741824 and 230-1 = 1073741823 (given respectively by the values Int.minInt and Int.maxInt). Some computations, however, require integers that are larger than these; for instance, RSA public key cryptography requires big integers. In this part of the problem set, we will write code that allows SML to handle arbitrarily large integers (well, as large as the memory allows).  We define an abstract type for bignums along with the standard operations plus, minus, times, comparison operators, and so on. The signature for the structure implementing bignums is in the file bignums.sig.

Abstraction Function

We will implement a structure BigNums that matches the signature BIGNUMS. In this implementation, an integer n will be represented as a list of integers, namely the coefficients of the expansion of n in some base. For example, suppose the base is 1000. Then the number 123456789 can be written as:

(123 * 10002) + (456 * 10001) + (789 * 10000),

which we will represent by the list [789,456,123]. Notice that the least-significant coefficient appears first in the list. 

For another example, consider the number 12000000000000, which is represented by the list [0,0,0,0,12]. Clearly, the initial 0's are important, as [12] is the representation for 12, [0,12] is the representation for 12000, [0,0,12] is the representation for 12000000, and so on.

Here is the actual type definition for bignums:

type bignum = {neg : bool, coeffs : int list}

The neg field specifies whether the number is positive (if neg is false) or negative (if neg is true). The coeffs field is the list of coefficient in some base, where each coefficient is between 0 and base-1, inclusive. Assuming a base of 1000, to represent the integer 123456789, we would use:

{neg=false, coeffs=[789,456,123]}

and to represent −9999 we would use:

{neg=true, coeffs=[999,9]}

In other words, the record {false, [a0, a1, a2, ..., an]} represents the integer a0 + a1*base + a2*base2 + ... + an*basen; an empty list represents 0. If neg=true, the record represents the corresponding negative integer. This defines the abstraction function for bignum.

The base used by your bignum implementation is defined in bignums.sml:

val base: int = 1000

To make it easier to implement some of the functions below (e,g, toString), assume that the base is one of 10, 100, 1000, or 10000. Regardless of the value of base, your implementation should give exactly the same answers as far as the user can tell; that is, the base should be hidden by the abstraction barrier.

Using your solution

After you implement the function toString in Problem 1, you will be able to test your functions by converting the result to strings and printing them. Here is a sample interaction with a completed version of the structure, in which we multiply 123456789 by 987654321:

- structure B = BigNums;
structure B : BIGNUMS
- B.times (B.fromInt(123456789),B.fromInt(987654321));
val it = - : BigNums.bignum
- B.toString (it);
val it = "121932631112635269" : string

Notice that we bind the structure name B to BigNums. This allows us to refer to functions in BigNums more easily. 

Exercise 1: Bignum Implementation

What you need to do: implement the following functions defined in bignums.sml, which operate on big numbers. We have provided the conversion function fromString  that might prove useful during testing.

  1. The implementation of big numbers will be simpler if we impose a representation invariant.  Along with the representation type of bignums, is included a comment that provides the abstraction function, your job is to complete this comment by filling in  the representation invariant for big numbers.  Moreover, write a repOK function that checks if a big number satisfies your representation invariant.
      
    val repOK : bignum -> bool
     
  2. Implement conversion functions between integers and big numbers:
     
    val toInt : bignum -> int option
    val fromInt : int -> bignum
     
  3. We have provided a function to convert a string to a bignum (the function fromString). Implement the opposite conversion, namely taking a bignum and returning a string representing that number in decimal form. A negative number should be prefixed by "~". Be sure to eliminate leading zeros; thus 1234 should be converted to "1234", not "001234". This function will allow you to view the results of computations. Hint: The library function StringCvt.padLeft can be handy for this problem.
     
    val toString : bignum -> String
     
  4. Implement the functions equal and less that compare two numbers b1 and b2 and return a boolean indicating whether b1 is equal to or less than b2, respectively.  Assume that the arguments satisfy the rep invariant.

    val equal : bignum * bignum -> bool
    val less : bignum * bignum -> bool
     
  5. Implement the functions negate, plus, and times (negation, addition, and multiplication). Use the traditional algorithms you learned in grade school, adapted to an arbitrary base. The main goal is correctness, so keep your code simple. Make sure your code works with positive numbers, negative numbers, and zero. Assume that the arguments satisfy the rep invariant.
     
    val negate : bignum -> bignum
    val plus : bignum * bignum -> bignum
    val times : bignum * bignum -> bignum
      
  6. Implement division and modulus for big numbers. The function divmod(m,n) yields a pair (q,r) consisting of the quotient q and the reminder r: m = n q + r, and r is between 0 and n-1.  
     
    val divmod : bignum * bignum -> bignum * bignum
     
    This operation is more difficult than the previous ones. Hint: You can do this in two steps. First, write a function that divides a big integer to a single coefficient. For instance, assume base 100, and divide [5,1,8] to 6. You need to scan from right to left, and divide the top, or top two coefficients to 6: 
     
        8 = 6 * 1 + 2
    201 = 6 * 33 + 3
    305 = 6 * 50 + 5
     
    The result quotient is [50,33,1] and the remainder is 5. You can assume that base is small enough such that an int can hold the number represented by two coefficients.
     
    Next, write the divmod function. Assume both numbers are positive (you can reduce cases where m or n are negative to this case). To divide number m with coefficients [m1, .., ms, ..., mt] to number n with coefficients [n1,..,ns], compute p = [ms,...,mt] / (ns+1) using the previous division function (or take p = [ms+1,..., mt], if ns+1 = base); if p is zero, set it to 1. Then compute divmod (m - np, n). The process terminates when m is less than n. The final result of the division will be the sum of all numbers p, and the remainder will be the last m.

To do: Implement the functions repOK, toString, toInt, fromInt, equal, less, negate, plus, times, and divmod in bignums.sml.

 

Part B: The RSA Cryptosystem

In a world that relies increasingly upon digital information, public key encryption and digital signatures play an important part in achieving private communication. RSA is a popular public-key encryption system. It is based on the fact that there are fast algorithms for exponentiation and for testing prime numbers—but no known fast algorithms for factoring large numbers. In this problem set you will implement a version of the RSA system. The algorithms will you implement have both a deep mathematical foundation and practical importance.

Cryptographic systems typically use keys for encryption and decryption. An encryption key is used to convert the original message (the plaintext) to coded form (the ciphertext). A corresponding decryption key is used to convert the ciphertext back to the original plaintext.

In traditional cryptographic systems, the same key is used for both encryption and decryption. Two parties can exchange coded messages only if they share a secret key. Since anyone who learned that key would be able to decode the messages, keys must be carefully guarded and transmitted only under tight security: for example, couriers handcuffed to locked, tamper-resistant briefcases!

Diffie and Hellman (1976) discovered a new approach to encryption and decryption: public key cryptography. In this approach, the encryption and decryption keys are different. Knowing the encryption key cannot help you find the decryption key. Thus you can publish your encryption key on the web, and anyone who wants to send you a secret message can use it to encode a message to send to you. You do not have to worry about key security at all, for even if everyone in the world knew your encryption key, no one could decrypt messages sent to you without knowing your decryption key, which you keep private to yourself.

The RSA system, due to Rivest, Shamir, and Adelman, is the best-known public-key cryptosystem; the security of your web browsing probably depends on it.

How RSA Works

RSA does not work with characters, but with integers. That is, it encrypts numbers. To encrypt a piece of text, just combine the ASCII codes of several characters into a big number, and then encrypt the resulting number.

Suppose you want to obtain public and private keys in the RSA system. You select two very large prime numbers p and q. (Recall that a prime number is a positive integer greater than 1 with no divisors other than itself and 1). You then compute numbers n and m:

n = pq
m = (p−1)(q−1)

It turns out that

(Notation: [a=b] mod n means that (a mod n) = (b mod n), where a mod n is the remainder obtained when dividing a by n using ordinary integer division. Equivalently, [a=b] mod n if ab is divisible by n. For example, [17 = 32] mod 5.)

Now you pick a number e < m relatively prime to m; that is, such that e and m have no factors in common except 1. The significance of relative primality is that e is relatively prime to m iff e is invertible mod m; that is, iff there exists a d such that [de = 1] mod m. Moreover, it is possible to compute d from e and m using Euclid's algorithm, described below. Your public key, which you can advertise to the world, is the pair (n,e). Your private key is (n,d).

Anyone who wants to send you a secret message s (represented by an integer) encrypts it by computing E(s), where

    E(s) = se mod n

That is, if the plaintext is represented by the number s which is less than n, the ciphertext E(s) is obtained by raising s to the power e, then taking the remainder modulo n.

The decryption process is exactly the same, except that d is used instead of e:

    D(s) = sd mod n

The operations E and D are inverses:

    D(E(s))    = (se)d mod n
                    = sde mod n
                    = s1+km mod n
                    = s(sm)k mod n
                    = s(1)k mod n
                    = s mod n   
                    = s

The integer s representing the plaintext must be less than n. That's why we break the message up into chunks. Also, this only works if s is relatively prime to n: it has no factors in common with n other than 1. If n is the product of two large primes, then all but negligibly few messages s < n satisfy this property. If by some freak chance s and n turned out not to be relatively prime, then the code would be broken; but the chances of this happening by accident are insignificantly small. In summary, to use the RSA system:

    1. pick large primes p and q;
    2. compute n = pq and m = (p−1)(q−1);
    3. choose e relatively prime to m and use this to compute d such that [de = 1] mod m;
    4. publish the pair (n,e) as your public key, but keep d, p, and q secret.

How secure is RSA? At present, the only known way to obtain d from e and n is to factor n into its prime factors p and q, then compute m and proceed as above. But despite centuries of effort by number theorists, factoring large integers efficiently is still an open problem. Until someone comes up with an efficient way to factor numbers, or discovers some other way to compute d from e and n, the cryptosystem appears to be secure for large numbers n. Several "better" factorization algorithms have been proposed, however, and one of them is discussed later in this assignment.

Exercise 2: Implement RSA

You have been given a partial implementation of the RSA algorithm in rsa.sml.  The given code provides additional functions for computations on big numbers; functions to generate prime numbers, and to test numbers for primality; code that generates key pairs; and functions to read and write keys to and from files. We briefly discuss a few of these functions.

Function expmod(b, e, m) computes be mod m. Function euclid(m,n) yields numbers (s,t,g) such that sm + tn = g, where g is the greatest common divisor of m and n

Function isPrime(n) tests is number is prime. This is a probabilistic test: it yields true if there is a high probability that n is prime; and yields false if n is definitely not prime. This function is based on Fermat's theorem, which states that if n is prime, then all numbers a in the range 0 < a < n satisfy [an-1 = 1] mod n; and if n is not prime, then at most half do. Therefore, successfully checking if [an-1 = 1] mod n for k numbers a indicates that n is prime with a probability of error of 1 / 2k.

The function generateKeyPair(r) generates an RSA key pair. It picks random primes p and q that are in the range from 2r to 2*2r so that n = pq will be in the range 22r to 22r+2. To find the keys e and d given m = (p-1)(q-1), function selectKeys picks a random e, and computes d using Euclid's algorithm.

You must add the encryption and decryption functions specified below. Your functions encode an input list of bytes into an output list of bytes. Your functions will operate on blocks of bytes. Each block consists of M bytes or (M-1) bytes, where M is the number of bytes required to store the modulus n of the key (n,e) or (n,d). Encryption and decryption then work as follows: take an (M-1)-byte block at a time (i.e. a list of M-1 bytes), convert it to a bignum, encode it with RSA, and then convert it to an M-byte list; you must pad the output list to ensure it has exactly M bytes. Moreover, the last block in the input may have less than M-1 bytes, so you'll also need to pad that last block with zeros, and store the amount of padding in the output list. The decryption function performs the opposite operation: it decodes each M-byte block into an M-1 byte block, and removes the appropriate amount of padding from the last block. You may find it useful to use a transform function to encrypt/decrypt one block at a time.

  (* transform k X l encodes list l into an X-byte block using key k.
   * List l might not have exactly X bytes.
   * Convert l to a bignum, encode the bignum, convert result back to
   * a list, and pad the result with zeros to form an X-byte list.
   * Returns: an X-byte list representing the encoded block. *)
  val transform : key -> int -> Word8.word list -> Word8.word list
  (* encrypt k l encrypts byte list l with key k.
   * If M is the number of bytes in k's modulus, then break l into
   * (M-1)-byte blocks, and encrypt each block into an M-byte output
   * block. Pad last input block to form an (M-1)-byte block.
   * Returns: a list n'::l', where n' is the amount of padding in the
   * last block, and l' is the concatenation of all encrypted blocks. *)
  val encrypt : key -> Word8.word list -> Word8.word list
 
  (* decrypt k l decrypts byte list l with key k.
   * Requires that l = n'::l', where n' is the amount of padding that
   * must be removed from the last block, and l' consists of encrypted
   * M-byte blocks. As above, M is the number of bytes of k's modulus.
   * The function decrypts each M-byte block into an (M-1)-byte block.
   * Returns: the decrypted list. *)
  val decrypt : key -> Word8.word list -> Word8.word list

To do:  Complete encrypt and decrypt in rsa.sml.

Exercise 3: Digital Signatures

Public key encryption can be used to solve another problem of secure communication. Suppose you wish to send a message by electronic mail and sign it so that the recipient can be sure that it really came from you. What is required is some scheme for signing a message in a way that cannot be forged. This is called a digital signature.

Suppose you want to sign a digital message M in a way that convinces anyone else that the message came from you. This can be achieved by providing a signature that could only have been sent by someone with your private key. This signature is simply D(M)

Anyone who receives M and D(M) can verify that the signature corresponding to the message by applying E to it, obtaining E(D(M)) = M. Only someone in possession of the private key d could have constructed a signature with this property. If someone wanted to forge a signature to a bogus message M, they would have to compute D(M), but assuming that RSA is secure, this cannot be done without the private key d.

We can combine the two techniques as follows. Suppose Amy wants to send Bob a message that only Bob can read, and sign the message so that Bob knows it could only be from her. She encrypts the message using Bob public key. Then she signs the encrypted result using her own private key using the signature method just described. When Bob receives the message, he first uses Amy's public key to authenticate the signature, then decrypts the message using his own private key.

Bob can be sure that only someone with Amy's private key could have sent the message. Amy can be sure that only someone with Bob's private key can read the message. This is accomplished without exchanging any secret information between Bob and Amy. It's this capacity for achieving secure communication without having to worry about exchanging secret keys that makes public key cryptography such an appealing technique.

In real implementations of digital signatures, the whole message M is not signed; instead, the result of applying a secure one-way hash function is signed. An example of such a function is MD5.

To Do: Implement the functions encryptAndSign and unsignAndDecrypt in rsa.sml

Exercise 4: Cracking RSA: Integer Factorization

The only way to break RSA is by factoring into its non-trivial factors p and q. Once the original p and q are found, the attacker (who knows e) can then solve for d. He can then decrypt messages intended for the holder of the original secret key, sign documents and impersonate the original keyholder, etc. RSA safety relies on the fact that factorization is difficult: it is computationally intractable for numbers that are large enough. For instance, it is infeasible with today's best supercomputers and best algorithms known to date to factor an arbitrary 1024-bit number.  In fact, the RSA Factoring Challenge offers money prizes for factoring large numbers. To date, the largest number that has been successfully factored had 576 bits (or 174 decimal digits).

The most obvious algorithm for factoring is trial division. Simply try all numbers from 2 up to n1/2. Therefore, this approach has complexity O(n1/2):

for i = 2 to sqrt(n)
  if (n mod i == 0)
    return (i, n div i)

 A significantly better factorization algorithm is the Pollard's Monte-Carlo factorization (or Pollard Rho) algorithm, which has complexity O(n1/4). For instance, in about 100 steps, trial division can factor a number of the order 10000, and Pollard Rho can factor a number of the order 100000000.

The idea of the algorithm is as follows. Start with a random initial number x1 and compute the sequence x2, x3, x4... such that:

    xi+1 = (xi2 - 1) mod n

Since there all the numbers in the sequence are less than n, the sequence will enter a cycle at some point: there are numbers xi and  xk such that  xi = xk . It can be shown that this cycle is expected to occur after about n1/2 steps.

Now consider the sequence x'1, x'2, x'3... such that x'i = xi mod p, where p is the smallest of the factors we're looking for. It can be shown that this sequence follows the same recurrence, but modulo p: x'i+1 = (x'i2 1) mod p. As above, it can be shown that this sequence will enter a cycle after about p1/2 steps. Since p is less than  n1/2 , {x'i} will enter a cycle after about  n1/4 steps. The interesting fact is that it's likely that we found the factor p once {x'i} enters the cycle: if x'i = x'k, then xi = xk mod p, so p divides xi - xk, hence gcd(xi - xk, n) is either p or n; it can be show that it's very likely that gcd yields p, not n.

The algorithm is then simply as follows: compute the sequence {xi} and, at each step, check if  gcd(xi - xk, n) is a non-trivial factor of n, where xk is a previous value from the sequence. More precisely, k is the last power of two less than i. Note that the sequence {x'i} is not computed (and it cannot be, since we don't know p yet); this sequence is used only as an argument for the complexity of this approach, that it's likely to get the factor after n1/4 steps. The pseudo-code of the algorithm is as follows:

 pollard(n0, n)
    i <- 1
    x <- n0
    y <- x
    k <- 2
    loop
      i <- i + 1
      x <- (x2 - 1) mod n
      d <- gcd (y-x, n)
      if (d ¹ 1 and d ¹n)
         return (d, n div d)
      if (i = k)
         y <- x
         k <- 2*k

Your job is to implement the Pollard Monte-Carlo factorization algorithm for big numbers, and to report the largest composite number n that you were able to factor.

(* pollard (n0, n) = a pair (p,q) of two non-trivial factors of n. 
 * The Monte-Carlo computation is seeded at n0. *)
val pollard : bignum * bignum -> bignum * bignum

To do: Implement pollard in factorization.sml, and bind largest to a string representing the largest number you were able to factor. 

 

Part C: Written Problem

Exercise 5: Induction and Program Correctness

Consider an implementation of sets in integers (positive and negative) using binary search trees, and a function that adds an element to a set.  Write the representation invariant, the abstraction function, and prove the correctness of the insertion function.

  datatype BST = Empty | Node of BST * int * BST
  type set = BST
  
  (* insert i s = add item i to set s. *)
  fun insert (item:int) (s:set):set =
	case s of
	  Empty => Node(Empty, item ,Empty)
	| Node(l, i ,r) => case Int.compare(item,i) of
				       LESS => Node(insert item l, i, r)
				     | EQUAL => s
				     | GREATER => Node(l, i, insert item r) 

Exercise 6: Asymptotic Complexity

  1. Rank the following asymptotic complexities, from lowest to highest: O(n log n), O(n2), O(n!), O(n), O(2n), O(nn), O(log log n)
     
  2. Assume that g(n) = O(2n)  and f(n) = O(3n).  Is g(n) = O(f(n))? Prove your answer.

 
To do: Hand in your proof in a file induction.txt.  This solution must be in ASCII text format, and all lines should be shorter than 78 characters.