diff --git a/java/com/samourai/wallet/api/backend/BackendApi.java b/java/com/samourai/wallet/api/backend/BackendApi.java index 872adcf..ac56eb7 100644 --- a/java/com/samourai/wallet/api/backend/BackendApi.java +++ b/java/com/samourai/wallet/api/backend/BackendApi.java @@ -2,7 +2,9 @@ import com.samourai.wallet.api.backend.beans.HttpException; import com.samourai.wallet.api.backend.beans.MultiAddrResponse; +import com.samourai.wallet.api.backend.beans.RefreshTokenResponse; import com.samourai.wallet.api.backend.beans.UnspentResponse; +import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -11,26 +13,31 @@ public class BackendApi { private Logger log = LoggerFactory.getLogger(BackendApi.class); - private static final String URL_UNSPENT = "/v2/unspent?active="; - private static final String URL_MULTIADDR = "/v2/multiaddr?active="; - private static final String URL_INIT_BIP84 = "/v2/xpub"; - private static final String URL_FEES = "/v2/fees"; - private static final String URL_PUSHTX = "/v2/pushtx/"; + private static final String URL_UNSPENT = "/unspent?active="; + private static final String URL_MULTIADDR = "/multiaddr?active="; + private static final String URL_INIT_BIP84 = "/xpub"; + private static final String URL_FEES = "/fees"; + private static final String URL_PUSHTX = "/pushtx/"; + private static final String URL_GET_AUTH_LOGIN = "/auth/login"; + private static final String URL_GET_AUTH_REFRESH = "/auth/refresh"; private IBackendClient httpClient; private String urlBackend; + private String apiKey; - public BackendApi(IBackendClient httpClient, String urlBackend) { + public BackendApi(IBackendClient httpClient, String urlBackend, String apiKey) { this.httpClient = httpClient; this.urlBackend = urlBackend; + this.apiKey = apiKey; // may be null } public List fetchUtxos(String zpub) throws Exception { - String url = urlBackend + URL_UNSPENT + zpub; + String url = computeAuthUrl(urlBackend + URL_UNSPENT + zpub); if (log.isDebugEnabled()) { log.debug("fetchUtxos: " + url); } - UnspentResponse unspentResponse = httpClient.getJson(url, UnspentResponse.class); + Map headers = computeHeaders(); + UnspentResponse unspentResponse = httpClient.getJson(url, UnspentResponse.class, headers); List unspentOutputs = new ArrayList(); if (unspentResponse.unspent_outputs != null) { @@ -40,11 +47,12 @@ public List fetchUtxos(String zpub) throws Except } public List fetchAddresses(String zpub) throws Exception { - String url = urlBackend + URL_MULTIADDR + zpub; + String url = computeAuthUrl(urlBackend + URL_MULTIADDR + zpub); if (log.isDebugEnabled()) { log.debug("fetchAddress: " + url); } - MultiAddrResponse multiAddrResponse = httpClient.getJson(url, MultiAddrResponse.class); + Map headers = computeHeaders(); + MultiAddrResponse multiAddrResponse = httpClient.getJson(url, MultiAddrResponse.class, headers); List addresses = new ArrayList(); if (multiAddrResponse.addresses != null) { addresses = Arrays.asList(multiAddrResponse.addresses); @@ -72,20 +80,22 @@ public MultiAddrResponse.Address fetchAddress(String zpub) throws Exception { } public void initBip84(String zpub) throws Exception { - String url = urlBackend + URL_INIT_BIP84; + String url = computeAuthUrl(urlBackend + URL_INIT_BIP84); if (log.isDebugEnabled()) { log.debug("initBip84: zpub=" + zpub); } + Map headers = computeHeaders(); Map postBody = new HashMap(); postBody.put("xpub", zpub); postBody.put("type", "new"); postBody.put("segwit", "bip84"); - httpClient.postUrlEncoded(url, Void.class, postBody); + httpClient.postUrlEncoded(url, Void.class, headers, postBody); } public SamouraiFee fetchFees() throws Exception { - String url = urlBackend + URL_FEES; - Map feeResponse = httpClient.getJson(url, Map.class); + String url = computeAuthUrl(urlBackend + URL_FEES); + Map headers = computeHeaders(); + Map feeResponse = httpClient.getJson(url, Map.class, headers); if (feeResponse == null) { throw new Exception("Invalid fee response from server"); } @@ -98,11 +108,12 @@ public void pushTx(String txHex) throws Exception { } else { log.info("pushTx..."); } - String url = urlBackend + URL_PUSHTX; + String url = computeAuthUrl(urlBackend + URL_PUSHTX); + Map headers = computeHeaders(); Map postBody = new HashMap(); postBody.put("tx", txHex); try { - httpClient.postUrlEncoded(url, Void.class, postBody); + httpClient.postUrlEncoded(url, Void.class, headers, postBody); } catch (HttpException e) { if (log.isDebugEnabled()) { log.error("pushTx failed", e); @@ -118,4 +129,58 @@ public void pushTx(String txHex) throws Exception { "PushTx failed (" + e.getResponseBody() + ") for txHex=" + txHex); } } + + protected RefreshTokenResponse.Authorization tokenAuthenticate() throws Exception { + String url = getUrlBackend() + URL_GET_AUTH_LOGIN; + if (log.isDebugEnabled()) { + log.debug("tokenAuthenticate"); + } + Map postBody = new HashMap(); + postBody.put("apikey", getApiKey()); + RefreshTokenResponse response = + getHttpClient().postUrlEncoded(url, RefreshTokenResponse.class, null, postBody); + + if (response.authorizations == null|| StringUtils.isEmpty(response.authorizations.access_token)) { + throw new Exception("Authorization refused. Invalid apiKey?"); + } + return response.authorizations; + } + + protected String tokenRefresh(String refreshToken) throws Exception { + String url = getUrlBackend() + URL_GET_AUTH_REFRESH; + if (log.isDebugEnabled()) { + log.debug("tokenRefresh"); + } + Map postBody = new HashMap(); + postBody.put("rt", refreshToken); + RefreshTokenResponse response = + getHttpClient().postUrlEncoded(url, RefreshTokenResponse.class, null, postBody); + + if (response.authorizations == null || StringUtils.isEmpty(response.authorizations.access_token)) { + throw new Exception("Authorization refused. Invalid apiKey?"); + } + return response.authorizations.access_token; + } + + protected Map computeHeaders() throws Exception { + Map headers = new HashMap(); + return headers; + } + + protected String computeAuthUrl(String url) throws Exception { + // override for auth support + return url; + } + + protected IBackendClient getHttpClient() { + return httpClient; + } + + protected String getApiKey() { + return apiKey; + } + + public String getUrlBackend() { + return urlBackend; + } } diff --git a/java/com/samourai/wallet/api/backend/IBackendClient.java b/java/com/samourai/wallet/api/backend/IBackendClient.java index 6b4efc9..47c1e8e 100644 --- a/java/com/samourai/wallet/api/backend/IBackendClient.java +++ b/java/com/samourai/wallet/api/backend/IBackendClient.java @@ -5,7 +5,7 @@ import java.util.Map; public interface IBackendClient { - T getJson(String url, Class responseType) throws HttpException; + T getJson(String url, Class responseType, Map headers) throws HttpException; - T postUrlEncoded(String url, Class responseType, Map body) throws HttpException; + T postUrlEncoded(String url, Class responseType, Map headers, Map body) throws HttpException; } diff --git a/java/com/samourai/wallet/api/backend/beans/RefreshTokenResponse.java b/java/com/samourai/wallet/api/backend/beans/RefreshTokenResponse.java new file mode 100644 index 0000000..e909cbe --- /dev/null +++ b/java/com/samourai/wallet/api/backend/beans/RefreshTokenResponse.java @@ -0,0 +1,14 @@ +package com.samourai.wallet.api.backend.beans; + +public class RefreshTokenResponse { + public Authorization authorizations; + + public RefreshTokenResponse() {} + + public static class Authorization { + public String access_token; + public String refresh_token; + + public Authorization(){} + } +} diff --git a/java/com/samourai/wallet/api/pairing/PairingPayload.java b/java/com/samourai/wallet/api/pairing/PairingPayload.java index 382502d..b77bcad 100644 --- a/java/com/samourai/wallet/api/pairing/PairingPayload.java +++ b/java/com/samourai/wallet/api/pairing/PairingPayload.java @@ -4,17 +4,22 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.net.URL; + public class PairingPayload { private static final Logger log = LoggerFactory.getLogger(PairingPayload.class); - private CliPairingValue pairing; + private PairingValue pairing; + private PairingDojo dojo; // may be null public PairingPayload() { - this.pairing = new CliPairingValue(); + this.pairing = new PairingValue(); + this.dojo = null; } - public PairingPayload(PairingType type, PairingVersion version, PairingNetwork network, String mnemonic, Boolean passphrase) { - this.pairing = new CliPairingValue(type, version, network, mnemonic, passphrase); + public PairingPayload(PairingType type, PairingVersion version, PairingNetwork network, String mnemonic, Boolean passphrase, PairingDojo dojo) { + this.pairing = new PairingValue(type, version, network, mnemonic, passphrase); + this.dojo = dojo; } protected void validate() throws Exception { @@ -22,28 +27,39 @@ protected void validate() throws Exception { throw new Exception("Invalid pairing"); } pairing.validate(); + if (dojo != null) { + dojo.validate(); + } } - public CliPairingValue getPairing() { + public PairingValue getPairing() { return pairing; } - public void setPairing(CliPairingValue pairing) { + public void setPairing(PairingValue pairing) { this.pairing = pairing; } - public static class CliPairingValue { + public PairingDojo getDojo() { + return dojo; + } + + public void setDojo(PairingDojo dojo) { + this.dojo = dojo; + } + + public static class PairingValue { private PairingType type; private PairingVersion version; private PairingNetwork network; private String mnemonic; private Boolean passphrase; // NULL for V1 - public CliPairingValue() { + public PairingValue() { - } + } - public CliPairingValue(PairingType type, PairingVersion version, PairingNetwork network, String mnemonic, Boolean passphrase) { + public PairingValue(PairingType type, PairingVersion version, PairingNetwork network, String mnemonic, Boolean passphrase) { this.type = type; this.version = version; this.network = network; @@ -110,4 +126,50 @@ public void setPassphrase(Boolean passphrase) { } } + public static class PairingDojo { + private String url; // may be null + private String apikey; // may be null + + public PairingDojo() { + } + + public PairingDojo(String url, String apikey) { + this.url = url; + this.apikey = apikey; + } + + protected void validate() throws Exception { + // url + if (StringUtils.isEmpty(url)) { + throw new Exception("Invalid pairing.url"); + } + try { + new URL(url); + } catch(Exception e) { + log.error("", e); + throw new Exception("Invalid pairing.url"); + } + + // apikey + if (StringUtils.isEmpty(apikey)) { + throw new Exception("Invalid pairing.apikey"); + } + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getApikey() { + return apikey; + } + + public void setApikey(String apikey) { + this.apikey = apikey; + } + } } diff --git a/java/com/samourai/wallet/api/pairing/PairingVersion.java b/java/com/samourai/wallet/api/pairing/PairingVersion.java index 6d80b45..0259866 100644 --- a/java/com/samourai/wallet/api/pairing/PairingVersion.java +++ b/java/com/samourai/wallet/api/pairing/PairingVersion.java @@ -5,7 +5,8 @@ public enum PairingVersion { V1_0_0("1.0.0"), - V2_0_0("2.0.0"); + V2_0_0("2.0.0"), + V3_0_0("3.0.0"); private String value; diff --git a/pom.xml b/pom.xml index a432474..4ac7cfb 100644 --- a/pom.xml +++ b/pom.xml @@ -48,6 +48,12 @@ jackson-annotations 2.9.0 + + com.fasterxml.jackson.core + jackson-databind + 2.9.9.1 + test + java diff --git a/src/test/java/com/samourai/wallet/api/pairing/PairingPayloadTest.java b/src/test/java/com/samourai/wallet/api/pairing/PairingPayloadTest.java new file mode 100755 index 0000000..ac184d2 --- /dev/null +++ b/src/test/java/com/samourai/wallet/api/pairing/PairingPayloadTest.java @@ -0,0 +1,161 @@ +package com.samourai.wallet.api.pairing; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class PairingPayloadTest { + private static ObjectMapper objectMapper; + + public PairingPayloadTest() { + objectMapper = new ObjectMapper(); + objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + } + + @Test + public void test() throws Exception { + String payload = "{\"pairing\":{\"type\":\"whirlpool.gui\",\"version\":\"2.0.0\",\"network\":\"testnet\",\"mnemonic\":\"P4ks9PBEaiMy7EIrT9ktP7sywY96nGxc0c+E4d5\\/vBrZx6bOpsSqyGkEgqKxUgwLKBeZkF+MiYNAuOLPtPG\\/beYwSEma98V5qZL7F\\/dZIxPaAHmsOAbN0gc55sbSErZ+\",\"passphrase\":true}}"; + PairingPayload pairingPayload = parse(payload); + pairingPayload.validate(); + Assertions.assertNull(pairingPayload.getDojo()); + } + + @Test + public void testDojo() throws Exception { + String payload = "{\"pairing\":{\"type\":\"whirlpool.gui\",\"version\":\"2.0.0\",\"network\":\"testnet\",\"mnemonic\":\"P4ks9PBEaiMy7EIrT9ktP7sywY96nGxc0c+E4d5\\/vBrZx6bOpsSqyGkEgqKxUgwLKBeZkF+MiYNAuOLPtPG\\/beYwSEma98V5qZL7F\\/dZIxPaAHmsOAbN0gc55sbSErZ+\",\"passphrase\":true},\"dojo\":{\"apikey\":\"foo\",\"url\":\"http:\\/\\/foo.onion\\/test\\/v2\\/\"}}"; + PairingPayload pairingPayload = parse(payload); + pairingPayload.validate(); + Assertions.assertEquals("foo", pairingPayload.getDojo().getApikey()); + Assertions.assertEquals("http://foo.onion/test/v2/", pairingPayload.getDojo().getUrl()); + } + + @Test + public void parse_valid() throws Exception { + String payload; + + // valid + payload = + "{\"pairing\":{\"type\":\"whirlpool.gui\",\"version\":\"1.0.0\",\"network\":\"testnet\",\"mnemonic\":\"foo\"}}"; + parse( + payload, + PairingVersion.V1_0_0, + PairingNetwork.TESTNET, + "foo", + null); + + // valid + payload = + "{\"pairing\":{\"type\":\"whirlpool.gui\",\"version\":\"1.0.0\",\"network\":\"testnet\",\"mnemonic\":\"foo\"}}"; + parse( + payload, + PairingVersion.V1_0_0, + PairingNetwork.TESTNET, + "foo", + null); + + // valid + payload = + "{\"pairing\": {\"type\": \"whirlpool.gui\",\"version\": \"1.0.0\",\"network\": \"mainnet\",\"mnemonic\": \"foo\"}}"; + parse( + payload, + PairingVersion.V1_0_0, + PairingNetwork.MAINNET, + "foo", + null); + + // valid V2 + payload = + "{\"pairing\":{\"type\":\"whirlpool.gui\",\"version\":\"2.0.0\",\"network\":\"testnet\",\"mnemonic\":\"foo\",\"passphrase\":true}}"; + parse( + payload, + PairingVersion.V2_0_0, + PairingNetwork.TESTNET, + "foo", + true); + + // valid V2 no passphrase + payload = + "{\"pairing\":{\"type\":\"whirlpool.gui\",\"version\":\"2.0.0\",\"network\":\"testnet\",\"mnemonic\":\"foo\",\"passphrase\":false}}"; + parse( + payload, + PairingVersion.V2_0_0, + PairingNetwork.TESTNET, + "foo", + false); + } + + @Test + public void parse_invalid() throws Exception { + // missing 'pairing' + try { + String payload = + "{\"wrong\":{\"type\":\"whirlpool.gui\",\"version\":\"1.0.0\",\"network\":\"testnet\",\"mnemonic\":\"foo\"}}"; + parse(payload); + Assertions.assertTrue(false); + } catch (Exception e) { + // ok + } + + // invalid type + try { + String payload = + "{\"pairing\":{\"type\":\"foo\",\"version\":\"1.0.0\",\"network\":\"testnet\",\"mnemonic\":\"foo\"}}"; + parse(payload, PairingVersion.V1_0_0, PairingNetwork.TESTNET, "foo", null); + Assertions.assertTrue(false); + } catch (Exception e) { + // ok + } + + // invalid version + try { + String payload = + "{\"pairing\":{\"type\":\"whirlpool.gui\",\"version\":\"0.0.0\",\"network\":\"testnet\",\"mnemonic\":\"foo\"}}"; + parse(payload, PairingVersion.V1_0_0, PairingNetwork.TESTNET, "foo", null); + Assertions.assertTrue(false); + } catch (Exception e) { + // ok + } + + // invalid network + try { + String payload = + "{\"pairing\":{\"type\":\"whirlpool.gui\",\"version\":\"1.0.0\",\"network\":\"wrong\",\"mnemonic\":\"foo\"}}"; + parse(payload, PairingVersion.V1_0_0, PairingNetwork.TESTNET, "foo", null); + Assertions.assertTrue(false); + } catch (Exception e) { + // ok + } + + // invalid mnemonic + try { + String payload = + "{\"pairing\":{\"type\":\"whirlpool.gui\",\"version\":\"1.0.0\",\"network\":\"testnet\",\"mnemonic\":\"\"}}"; + parse(payload, PairingVersion.V1_0_0, PairingNetwork.TESTNET, "foo", null); + Assertions.assertTrue(false); + } catch (Exception e) { + // ok + } + } + + private void parse( + String payload, + PairingVersion pairingVersion, + PairingNetwork pairingNetwork, + String mnemonic, + Boolean passphrase) + throws Exception { + PairingPayload pairingPayload = parse(payload); + Assertions.assertEquals(pairingNetwork, pairingPayload.getPairing().getNetwork()); + Assertions.assertEquals(pairingVersion, pairingPayload.getPairing().getVersion()); + Assertions.assertEquals(mnemonic, pairingPayload.getPairing().getMnemonic()); + Assertions.assertEquals(passphrase, pairingPayload.getPairing().getPassphrase()); + } + + private PairingPayload parse(String json) throws Exception { + PairingPayload pairingPayload = objectMapper.readValue(json, PairingPayload.class); + pairingPayload.validate(); + return pairingPayload; + } +}