How to convert a byte array to a string under a restrictive character set?

I would like to apply a cryptographic hash to an IP number and have the hash be in the character set "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_".

My trials so far:

    String ipWithSecret = secret + "123.123.123.123";
    byte[] ipBytes = ipWithSecret.getBytes(StandardCharsets.UTF_8);
    MessageDigest md = MessageDigest.getInstance("MD5");
    byte[] mdBytes = md.digest(ipBytes);
    System.out.println("MD5:      " + mdBytes);
    System.out.println("US ASCII: " + new String(mdBytes, StandardCharsets.US_ASCII));
    System.out.println("Hex:      " + HexBin.encode(mdBytes));
    System.out.println("Base64:   " + Base64.encodeBase64String(mdBytes));        
    System.out.print("Binary:   ");
    for (byte b : mdBytes) {
        System.out.print(Integer.toBinaryString(b & 255 | 256).substring(1));
    }

outputs:

MD5:      [B@6d6f6e28
US ASCII: :???RN???z?}k^?
Hex:      3A8BB1881E524EDFEFE67AFF7D6B5E9D
Base64:   OouxiB5STt/v5nr/fWtenQ==
Binary:   00111010100010111011000110001000000111100101001001001110110111111110111111100110011110101111111101111101011010110101111010011101

Besides the hex, nothing is even remotely acceptable. My available alphabet is much larger than the hex one and, because I have to deal with a length limit of 16 characters, I would like to get hash string shorter.

Any suggestions how I can achieve this?

Answer I modified John's answer a little bit so credit goes to him. Here's my code that also handles collisions somewhat:

    String secret = "secret";  //config file, do not store in code
    Set existingMdBase64Alt = new HashSet(Arrays.asList("OouxiB5STt_v5nr_", "ouxiB5STt_v5nr_f"));
    String ipWithSecret = secret + "123.123.123.123";
    byte[] ipBytes = ipWithSecret.getBytes(StandardCharsets.UTF_8);
    MessageDigest md = MessageDigest.getInstance("MD5");
    byte[] mdBytes = md.digest(ipBytes);
    String mdBase64 = Base64.encodeBase64String(mdBytes);
    String mdBase64Alt = mdBase64.replace("+","_").replace("/","_" );
    System.out.println("Debug: " + mdBase64Alt.substring(0, 16));
    while (mdBase64Alt.length() > 16 && existingMdBase64Alt.contains(mdBase64Alt.substring(0, 16))){
        mdBase64Alt = mdBase64Alt.substring(1);
        System.out.println("Debug: " + mdBase64Alt.substring(0, 16));
    }
    System.out.println("Final: " + mdBase64Alt.substring(0, 16));
Jon Skeet
people
quotationmark

In general, don't reinvent the wheel: use base64. That's the general "shorter than hex, but still printable ASCII" solution. There are various base64 options available, depending on which version of Java you're using etc. If there's nothing in the core framework you're using, I like the iharder.net public domain implementation.

However, that's not really going to help you here. An MD5 hash is 16 bytes - so unless you've got 256 characters at your disposal (to represent each byte with a single character) you're going to end up with more than 16 characters, just because of the amount of information you're trying to represent.

As noted in the comments though, an IPv4 address is just 4 bytes. Frankly, hashing that isn't going to obscure it very much, as hashing the whole address space is eminently feasible...

people

See more on this question at Stackoverflow