Skip to content
This repository has been archived by the owner on Jun 1, 2022. It is now read-only.

Commit

Permalink
Fixes #374 (#411)
Browse files Browse the repository at this point in the history
* Align the SPSP connection generator with the JS and Rust implementations.
* Allow local parts that are longer than 32 bytes.
* Allow a configurable `streamServerSecretGenerator`.

Signed-off-by: David Fuelling <sappenin@gmail.com>
  • Loading branch information
sappenin authored and theotherian committed Jan 15, 2020
1 parent b319baf commit 59dbced
Show file tree
Hide file tree
Showing 2 changed files with 196 additions and 63 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,36 @@
public class SpspStreamConnectionGenerator implements StreamConnectionGenerator {

private static final Charset US_ASCII = StandardCharsets.US_ASCII;
private static final byte[] STREAM_SERVER_SECRET_GENERATOR = "ilp_stream_secret_generator".getBytes(US_ASCII);
// private static final byte[] STREAM_SERVER_SECRET_GENERATOR = "ilp_stream_secret_generator".getBytes(US_ASCII);

private final byte[] streamServerSecretGenerator;

/**
* No-args Constructor.
*/
public SpspStreamConnectionGenerator() {
// Note that by default, we are using the same magic bytes as the Javascript implementation but this is not
// strictly necessary. These magic bytes need to be the same for the server that creates the STREAM details for a
// given packet and for the server that fulfills those packets, but in the vast majority of cases those two servers
// will be running the same STREAM implementation so it doesn't matter what this string is. However, for more
// control, see the required-args Constructor.
this("ilp_stream_shared_secret");
}

/**
* Required-args constructor.
*
* @param streamServerSecretGenerator A set of magic bytes that act as a secret-generator seed for generating SPSP
* shared secrets. These magic bytes need to be the same for the server that
* creates the STREAM details for a given packet and for the server that fulfills
* those packets, but in the vast majority of cases those two servers will be
* running the same STREAM implementation so it doesn't matter what this string
* is.
*/
public SpspStreamConnectionGenerator(final String streamServerSecretGenerator) {
this.streamServerSecretGenerator = Objects.requireNonNull(streamServerSecretGenerator)
.getBytes(StandardCharsets.US_ASCII);
}

@Override
public StreamConnectionDetails generateConnectionDetails(
Expand All @@ -34,33 +63,20 @@ public StreamConnectionDetails generateConnectionDetails(
Objects.requireNonNull(receiverAddress, "receiverAddress must not be null");
Preconditions.checkArgument(serverSecretSupplier.get().length >= 32, "Server secret must be 32 bytes");

// base_address + "." + 32-bytes encoded as base64url
final Builder streamConnectionDetailsBuilder = StreamConnectionDetails.builder();
final byte[] token = Random.randBytes(18);
final String tokenBase64 = Base64.getUrlEncoder().withoutPadding().encodeToString(token);
final InterledgerAddress destinationAddress = receiverAddress.with(tokenBase64);

final byte[] randomBytes = Random.randBytes(18);
// Note the shared-secret is generated from the token's base64-encoded String bytes rather than from the
// _actual_ Base64-unencoded bytes. E.g., "foo".getBytes() is not the same as Base64.getDecoder().decode("foo")
final byte[] sharedSecret = Hashing
.hmacSha256(secretGenerator(serverSecretSupplier))
.hashBytes(randomBytes)
.hashBytes(tokenBase64.getBytes(StandardCharsets.US_ASCII))
.asBytes();

final String destinationAddressPrecursor =
receiverAddress.with(Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes)).getValue();

// The authTag is the first 14 bytes of the HmacSha256 of destinationAddressPrecursor
final byte[] authTag = Arrays.copyOf(
Hashing.hmacSha256(sharedSecret)
.hashBytes(destinationAddressPrecursor.getBytes(US_ASCII))
.asBytes(),
14
);

final InterledgerAddress destinationAddress = InterledgerAddress.of(
destinationAddressPrecursor + Base64.getUrlEncoder().withoutPadding().encodeToString(authTag)
);

return streamConnectionDetailsBuilder
.sharedSecret(SharedSecret.of(sharedSecret))
return StreamConnectionDetails.builder()
.destinationAddress(destinationAddress)
.sharedSecret(SharedSecret.of(sharedSecret))
.build();
}

Expand All @@ -71,36 +87,18 @@ public SharedSecret deriveSecretFromAddress(
Objects.requireNonNull(receiverAddress);

final String receiverAddressAsString = receiverAddress.getValue();
// For Javascript compatibility, the `localpart` is not treated as a base64-encoded string of bytes, but is instead
// treated simply as US-ASCII bytes.
final String localPart = receiverAddressAsString.substring(receiverAddressAsString.lastIndexOf(".") + 1);

final byte[] localPartBytes = Base64.getUrlDecoder().decode(localPart);
if (localPartBytes.length != 32) {
throw new StreamException(
String.format("Invalid Receiver Address (should have been 32 byte long): %s", receiverAddress));
}

// Bytes 0 through 17
final byte[] randomBytes = Arrays.copyOf(localPartBytes, 18);
final byte[] sharedSecret = Hashing.hmacSha256(secretGenerator(serverSecretSupplier)).hashBytes(randomBytes)
final byte[] sharedSecret = Hashing
.hmacSha256(secretGenerator(serverSecretSupplier))
.hashBytes(localPart.getBytes(StandardCharsets.US_ASCII))
.asBytes();
// Bytes 18 through 31
final byte[] authTag = Arrays.copyOfRange(localPartBytes, 18, localPartBytes.length);

// The Address without the final 18 bytes.
String addressWithoutBytes = receiverAddressAsString.substring(0, receiverAddressAsString.length() - 19);
byte[] derivedAuthTag = Hashing.hmacSha256(sharedSecret)
.hashBytes(addressWithoutBytes.getBytes(US_ASCII)).asBytes();
derivedAuthTag = Arrays.copyOf(derivedAuthTag, 14);

if (!Arrays.equals(derivedAuthTag, authTag)) {
throw new StreamException("Invalid Receiver Address (derived AuthTag failure)!");
}

return SharedSecret.of(sharedSecret);
}

/**
* Helper method to compute HmacSha256 on {@link #STREAM_SERVER_SECRET_GENERATOR}.
* Helper method to compute HmacSha256 on {@link #streamServerSecretGenerator}.
*
* @param serverSecretSupplier A {@link Supplier} for this node's main secret, which is the root seed for all derived
* secrets provided by this node.
Expand All @@ -109,6 +107,6 @@ public SharedSecret deriveSecretFromAddress(
*/
private byte[] secretGenerator(final ServerSecretSupplier serverSecretSupplier) {
Objects.requireNonNull(serverSecretSupplier);
return Hashing.hmacSha256(serverSecretSupplier.get()).hashBytes(STREAM_SERVER_SECRET_GENERATOR).asBytes();
return Hashing.hmacSha256(serverSecretSupplier.get()).hashBytes(this.streamServerSecretGenerator).asBytes();
}
}
Original file line number Diff line number Diff line change
@@ -1,26 +1,49 @@
package org.interledger.stream.receiver;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.fail;

import org.interledger.codecs.stream.StreamCodecContextFactory;
import org.interledger.core.InterledgerAddress;
import org.interledger.core.InterledgerPreparePacket;
import org.interledger.core.SharedSecret;
import org.interledger.spsp.StreamConnectionDetails;
import org.interledger.stream.StreamException;
import org.interledger.stream.Denomination;
import org.interledger.stream.crypto.JavaxStreamEncryptionService;
import org.interledger.stream.crypto.Random;

import com.google.common.io.BaseEncoding;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.mockito.MockitoAnnotations;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.Objects;

/**
* Unit tests for {@link SpspStreamConnectionGenerator}.
*/
public class SpspStreamStreamConnectionGeneratorTest {

private ServerSecretSupplier serverSecret;
@Rule
public ExpectedException expectedException = ExpectedException.none();

private SpspStreamConnectionGenerator connectionGenerator;
private ServerSecretSupplier serverSecretSupplier;
private JavaxStreamEncryptionService streamEncryptionService;
private StreamConnectionGenerator connectionGenerator;

@Before
public void setUp() {
serverSecret = () -> new byte[32];
MockitoAnnotations.initMocks(this);

// Always new and unique for each run, unless overridden in a test.
final byte[] serverSecret = Random.randBytes(32);
this.serverSecretSupplier = () -> serverSecret;

this.streamEncryptionService = new JavaxStreamEncryptionService();
this.connectionGenerator = new SpspStreamConnectionGenerator();
}

Expand Down Expand Up @@ -48,25 +71,137 @@ public void generateConnectionDetailsWithNullReceiverAddress() {
public void generateConnectionDetailsAndDeriveSecret() {
InterledgerAddress receiverAddress = InterledgerAddress.of("example.receiver");
StreamConnectionDetails connectionDetails = connectionGenerator
.generateConnectionDetails(serverSecret, receiverAddress);
.generateConnectionDetails(serverSecretSupplier, receiverAddress);

assertThat(connectionDetails.destinationAddress().startsWith(receiverAddress)).isTrue();
assertThat(connectionDetails.sharedSecret()).isEqualTo(
connectionGenerator.deriveSecretFromAddress(serverSecret, connectionDetails.destinationAddress()));
connectionGenerator.deriveSecretFromAddress(serverSecretSupplier, connectionDetails.destinationAddress()));
}

@Test(expected = StreamException.class)
public void assertErrorsIfCannotDeriveAddress() {
@Test
public void validateLocalPartsLongerThan32Bytes() {
InterledgerAddress receiverAddress = InterledgerAddress.of("example.receiver")
.with("55412631d66b49073b0c36e17a29ba266164fc508bd3eb1c8fc718a02907ce01");

try {
connectionGenerator.deriveSecretFromAddress(serverSecret, receiverAddress);
} catch (StreamException e) {
assertThat(e.getMessage()).isEqualTo(
"Invalid Receiver Address (should have been 32 byte long): InterledgerAddress{"
+ "value=example.receiver.55412631d66b49073b0c36e17a29ba266164fc508bd3eb1c8fc718a02907ce01}");
throw e;
}
SharedSecret result = connectionGenerator.deriveSecretFromAddress(serverSecretSupplier, receiverAddress);
assertThat(result).isNotNull();
}

@Test
public void fulfillsPacketsSentToJavaReceiver() throws IOException {
serverSecretSupplier = () -> new byte[32]; // All 0's
final InterledgerAddress ilpAddress = InterledgerAddress.of("example.connie.bob");

// This prepare packet was taken from Quilt's IlpPacketEmitter using the shared-secret defined below to encrypt
// the Stream frames.
// Receiver Addr: example.connie.bob.QeJvQtFp7eRiNhnoAg9PkusR
// SharedSecret: nHYRcu5KM5pyw8XehssZtvhEgCgkKP4Do5kJUpk84G4
final byte[] bytes = BaseEncoding.base16().decode(
"0C81AC00000000000000013230323031313235323235323431303435158251C52603B11F2C1612F5D4E852CBD63137B10D76B21D02"
+ "452CE9AD1549E22B6578616D706C652E636F6E6E69652E626F622E51654A7651744670376552694E686E6F416739506B75735246FD3B"
+ "915F81129603E73BFE46E1798539372A7D8A64529E954E1612FCF126E13D605FBF8E3709D8A9AFEE49312312718AED03F11B7C46B247"
+ "5FF3A5D2364FD4AEE229B84A618B"
);

final ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
final InterledgerPreparePacket prepare = StreamCodecContextFactory.oer().read(InterledgerPreparePacket.class, bais);

final SharedSecret sharedSecret = connectionGenerator
.deriveSecretFromAddress(() -> serverSecretSupplier.get(), ilpAddress.with("QeJvQtFp7eRiNhnoAg9PkusR"));

assertThat(BaseEncoding.base64().encode(sharedSecret.key()))
.withFailMessage("Did not regenerate the same shared secret")
.isEqualTo("nHYRcu5KM5pyw8XehssZtvhEgCgkKP4Do5kJUpk84G4=");

final StatelessStreamReceiver streamReceiver = new StatelessStreamReceiver(
serverSecretSupplier, connectionGenerator, streamEncryptionService,
StreamCodecContextFactory.oer()
);

final Denomination denomination = Denomination.builder()
.assetScale((short) 9)
.assetCode("ABC")
.build();

streamReceiver.receiveMoney(prepare, ilpAddress, denomination).handle(
fulfillPacket -> assertThat(fulfillPacket.getFulfillment().validateCondition(prepare.getExecutionCondition()))
.withFailMessage("fulfillment generated does not hash to the expected condition")
.isTrue(),
rejectPacket -> fail()
);
}

@Test
public void fulfillsPacketsSentToJavascriptReceiver() throws IOException {
// This was created by the JS ilp-protocol-stream library
//let ilp_address = Address::from_str("test.peerB").unwrap();
final InterledgerAddress ilpAddress = InterledgerAddress.of("test.peerB");

// This prepare packet was taken from the JS implementation
final byte[] bytes = BaseEncoding.base16().decode(
"0C819900000000000001F43230313931303238323134313533383338F31A96346C613011947F39A0F1F4E573C2FC3E7E53797672B01D28"
+ "98E90C9A0723746573742E70656572422E4E6A584430754A504275477A353653426D4933755836682D3B6CC484C0D4E9282275D4B37"
+ "C6AE18F35B497DDBFCBCE6D9305B9451B4395C3158AA75E05BF27582A237109EC6CA0129D840DA7ABD96826C8147D0D"
);

final ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
final InterledgerPreparePacket prepare = StreamCodecContextFactory.oer().read(InterledgerPreparePacket.class, bais);

serverSecretSupplier = () -> new byte[32]; // All 0's

final SharedSecret sharedSecret = connectionGenerator
.deriveSecretFromAddress(() -> serverSecretSupplier.get(), prepare.getDestination());

assertThat(BaseEncoding.base16().encode(sharedSecret.key()))
.withFailMessage("Did not regenerate the same shared secret")
.isEqualTo("B7D09D2E16E6F83C55B60E42FCD7C2B8ED49624A1DF73C59B383DBE2E8690309");

final StatelessStreamReceiver streamReceiver = new StatelessStreamReceiver(
serverSecretSupplier, connectionGenerator, streamEncryptionService,
StreamCodecContextFactory.oer()
);

final Denomination denomination = Denomination.builder()
.assetScale((short) 9)
.assetCode("ABC")
.build();

streamReceiver.receiveMoney(prepare, ilpAddress, denomination).handle(
fulfillPacket -> assertThat(fulfillPacket.getFulfillment().validateCondition(prepare.getExecutionCondition()))
.withFailMessage("fulfillment generated does not hash to the expected condition")
.isTrue(),
rejectPacket -> fail("Should have rejected, but did not!")
);
}

@Test
public void usingConfigurableSpspStreamConnectionGenerator() {
testDecrypt(new SpspStreamConnectionGenerator());
}

@Test
public void usingSpspStreamConnectionGenerator() {
testDecrypt(new SpspStreamConnectionGenerator());
}

/**
* Helper method to validate that {@code connectionGenerator} can generate a shared secret and a receiver address,
* both of which can be used to re-derive a shared secret that can encrypt/decrypt the same data.
*/
private void testDecrypt(final StreamConnectionGenerator connectionGenerator) {
Objects.requireNonNull(connectionGenerator);

final StreamConnectionDetails connectionDetails =
connectionGenerator.generateConnectionDetails(serverSecretSupplier, InterledgerAddress.of("test.address.foo"));

final SharedSecret derivedSharedSecret = connectionGenerator
.deriveSecretFromAddress(() -> serverSecretSupplier.get(), connectionDetails.destinationAddress());

// Assert that encrypt + decrypt are the same for the generated shared-secret and the derived shared secret
final String unencrypted = "bar";
final byte[] cipherText = streamEncryptionService.encrypt(connectionDetails.sharedSecret(), unencrypted.getBytes());

assertThat(streamEncryptionService.decrypt(derivedSharedSecret, cipherText))
.isEqualTo(unencrypted.getBytes());
}
}

0 comments on commit 59dbced

Please sign in to comment.