blob: 2b73653b23caab742a9088b221ed58c79f852cf2 [file] [log] [blame]
// Copyright 2020 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.security.cryptauth.lib.securegcm;
import com.google.common.io.BaseEncoding;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.ProcessBuilder.Redirect;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Arrays;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import javax.annotation.Nullable;
/**
* A wrapper to execute and interact with the //security/cryptauth/lib/securegcm:ukey2_shell binary.
*
* <p>This binary is a shell over the C++ implementation of the UKEY2 protocol, so this wrapper is
* used to test compatibility between the C++ and Java implementations.
*
* <p>The ukey2_shell is invoked as follows:
*
* <pre>{@code
* ukey2_shell --mode=<mode> --verification_string_length=<length>
* }</pre>
*
* where {@code mode={initiator, responder}} and {@code verification_string_length} is a positive
* integer.
*/
public class Ukey2ShellCppWrapper {
// The path the the ukey2_shell binary.
private static final String BINARY_PATH = "build/src/main/cpp/src/securegcm/ukey2_shell";
// The time to wait before timing out a read or write operation to the shell.
@SuppressWarnings("GoodTime") // TODO(b/147378611): store a java.time.Duration instead
private static final long IO_TIMEOUT_MILLIS = 5000;
public enum Mode {
INITIATOR,
RESPONDER
}
private final Mode mode;
private final int verificationStringLength;
private final ExecutorService executorService;
@Nullable private Process shellProcess;
private boolean secureContextEstablished;
/**
* @param mode The mode to run the shell in (initiator or responder).
* @param verificationStringLength The length of the verification string used in the handshake.
*/
public Ukey2ShellCppWrapper(Mode mode, int verificationStringLength) {
this.mode = mode;
this.verificationStringLength = verificationStringLength;
this.executorService = Executors.newSingleThreadExecutor();
}
/**
* Begins execution of the ukey2_shell binary.
*
* @throws IOException
*/
public void startShell() throws IOException {
if (shellProcess != null) {
throw new IllegalStateException("Shell already started.");
}
String modeArg = "--mode=" + getModeString();
String verificationStringLengthArg = "--verification_string_length=" + verificationStringLength;
final ProcessBuilder builder =
new ProcessBuilder(BINARY_PATH, modeArg, verificationStringLengthArg);
// Merge the shell's stderr with the stderr of the current process.
builder.redirectError(Redirect.INHERIT);
shellProcess = builder.start();
}
/**
* Stops execution of the ukey2_shell binary.
*
* @throws IOException
*/
public void stopShell() {
if (shellProcess == null) {
throw new IllegalStateException("Shell not started.");
}
shellProcess.destroy();
}
/**
* @return the handshake message read from the shell.
* @throws IOException
*/
public byte[] readHandshakeMessage() throws IOException {
return readFrameWithTimeout();
}
/**
* Sends the handshake message to the shell.
*
* @param message
* @throws IOException
*/
public void writeHandshakeMessage(byte[] message) throws IOException {
writeFrameWithTimeout(message);
}
/**
* Reads the auth string from the shell and compares it with {@code authString}. If verification
* succeeds, then write "ok" back as a confirmation.
*
* @param authString the auth string to compare to.
* @throws IOException
*/
public void confirmAuthString(byte[] authString) throws IOException {
byte[] shellAuthString = readFrameWithTimeout();
if (!Arrays.equals(authString, shellAuthString)) {
throw new IOException(
String.format(
"Unable to verify auth string: 0x%s != 0x%s",
BaseEncoding.base16().encode(authString),
BaseEncoding.base16().encode(shellAuthString)));
}
writeFrameWithTimeout("ok".getBytes());
secureContextEstablished = true;
}
/**
* Sends {@code payload} to be encrypted by the shell. This function can only be called after a
* handshake is performed and a secure context established.
*
* @param payload the data to be encrypted.
* @return the encrypted message returned by the shell.
* @throws IOException
*/
public byte[] sendEncryptCommand(byte[] payload) throws IOException {
writeFrameWithTimeout(createExpression("encrypt", payload));
return readFrameWithTimeout();
}
/**
* Sends {@code message} to be decrypted by the shell. This function can only be called after a
* handshake is performed and a secure context established.
*
* @param message the data to be decrypted.
* @return the decrypted payload returned by the shell.
* @throws IOException
*/
public byte[] sendDecryptCommand(byte[] message) throws IOException {
writeFrameWithTimeout(createExpression("decrypt", message));
return readFrameWithTimeout();
}
/**
* Requests the session unique value from the shell. This function can only be called after a
* handshake is performed and a secure context established.
*
* @return the session unique value returned by the shell.
* @throws IOException
*/
public byte[] sendSessionUniqueCommand() throws IOException {
writeFrameWithTimeout(createExpression("session_unique", null));
return readFrameWithTimeout();
}
/**
* Reads a frame from the shell's stdout with a timeout.
*
* @return The contents of the frame.
* @throws IOException
*/
private byte[] readFrameWithTimeout() throws IOException {
Future<byte[]> future =
executorService.submit(
new Callable<byte[]>() {
@Override
public byte[] call() throws Exception {
return readFrame();
}
});
try {
return future.get(IO_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
} catch (InterruptedException | ExecutionException | TimeoutException e) {
throw new IOException(e);
}
}
/**
* Writes a frame to the shell's stdin with a timeout.
*
* @param contents the contents of the frame.
* @throws IOException
*/
private void writeFrameWithTimeout(final byte[] contents) throws IOException {
Future<?> future =
executorService.submit(
new Runnable() {
@Override
public void run() {
try {
writeFrame(contents);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
});
try {
future.get(IO_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
} catch (InterruptedException | ExecutionException | TimeoutException e) {
throw new IOException(e);
}
}
/**
* Reads a frame from the shell's stdout, which has the format:
*
* <pre>{@code
* +---------------------+-----------------+
* | 4-bytes | |length| bytes |
* +---------------------+-----------------+
* | (unsigned) length | contents |
* +---------------------+-----------------+
* }</pre>
*
* @return the contents that were read
* @throws IOException
*/
private byte[] readFrame() throws IOException {
if (shellProcess == null) {
throw new IllegalStateException("Shell not started.");
}
InputStream inputStream = shellProcess.getInputStream();
byte[] lengthBytes = new byte[4];
if (inputStream.read(lengthBytes) != lengthBytes.length) {
throw new IOException("Failed to read length.");
}
int length = ByteBuffer.wrap(lengthBytes).order(ByteOrder.BIG_ENDIAN).getInt();
if (length < 0) {
throw new IOException("Length too large: " + Arrays.toString(lengthBytes));
}
byte[] contents = new byte[length];
int bytesRead = inputStream.read(contents);
if (bytesRead != length) {
throw new IOException("Failed to read entire contents: " + bytesRead + " != " + length);
}
return contents;
}
/**
* Writes a frame to the shell's stdin, which has the format:
*
* <pre>{@code
* +---------------------+-----------------+
* | 4-bytes | |length| bytes |
* +---------------------+-----------------+
* | (unsigned) length | contents |
* +---------------------+-----------------+
* }</pre>
*
* @param contents the contents to send.
* @throws IOException
*/
private void writeFrame(byte[] contents) throws IOException {
if (shellProcess == null) {
throw new IllegalStateException("Shell not started.");
}
// The length is big-endian encoded, network byte order.
long length = contents.length;
byte[] lengthBytes = new byte[4];
lengthBytes[0] = (byte) (length >> 32 & 0xFF);
lengthBytes[1] = (byte) (length >> 16 & 0xFF);
lengthBytes[2] = (byte) (length >> 8 & 0xFF);
lengthBytes[3] = (byte) (length >> 0 & 0xFF);
OutputStream outputStream = shellProcess.getOutputStream();
outputStream.write(lengthBytes);
outputStream.write(contents);
outputStream.flush();
}
/**
* Creates an expression to be processed when a secure connection is established, after the
* handshake is done.
*
* @param command The command to send.
* @param argument The argument of the command. Can be null.
* @return the expression that can be sent to the shell.
* @throws IOException.
*/
private byte[] createExpression(String command, @Nullable byte[] argument) throws IOException {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
outputStream.write(command.getBytes());
outputStream.write(" ".getBytes());
if (argument != null) {
outputStream.write(argument);
}
return outputStream.toByteArray();
}
/** @return the mode string to use in the argument to start the ukey2_shell process. */
private String getModeString() {
switch (mode) {
case INITIATOR:
return "initiator";
case RESPONDER:
return "responder";
default:
throw new IllegalArgumentException("Uknown mode " + mode);
}
}
}