Use location hash instead of query parameters in share URL for increased privacy

This commit is contained in:
Sylvia van Os
2021-07-10 18:33:54 +02:00
parent c5f0ee3a66
commit 7324353d74
6 changed files with 122 additions and 54 deletions

View File

@@ -2,6 +2,10 @@
## Unreleased
Breaking changes:
- The backup format changed, see https://github.com/TheLastProject/Catima/wiki/Export-format
- The URL sharing format changed, see https://github.com/TheLastProject/Catima/wiki/Card-sharing-URL-format
Changes:
- Add UPC-E support

View File

@@ -7,9 +7,14 @@ import android.net.Uri;
import com.google.zxing.BarcodeFormat;
import java.io.InvalidObjectException;
import java.io.UnsupportedEncodingException;
import java.math.BigDecimal;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Currency;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
public class ImportURIHelper {
@@ -58,78 +63,114 @@ public class ImportURIHelper {
Currency balanceType = null;
Integer headerColor = null;
String store = uri.getQueryParameter(STORE);
String note = uri.getQueryParameter(NOTE);
String cardId = uri.getQueryParameter(CARD_ID);
String barcodeId = uri.getQueryParameter(BARCODE_ID);
if (store == null || note == null || cardId == null) throw new InvalidObjectException("Not a valid import URI");
// Store everything in a simple key/value hashmap
HashMap<String, String> kv = new HashMap<>();
String unparsedBarcodeType = uri.getQueryParameter(BARCODE_TYPE);
// First, grab all query parameters (backwards compatibility)
for (String key : uri.getQueryParameterNames()) {
kv.put(key, uri.getQueryParameter(key));
}
// Then, parse the new and more private fragment part
// Overriding old format entries if they exist
String fragment = uri.getFragment();
if (fragment != null) {
for (String fragmentPart : fragment.split("&")) {
String[] fragmentData = fragmentPart.split("=", 2);
kv.put(fragmentData[0], URLDecoder.decode(fragmentData[1], StandardCharsets.UTF_8.toString()));
}
}
// Then use all values we care about
String store = kv.get(STORE);
String note = kv.get(NOTE);
String cardId = kv.get(CARD_ID);
String barcodeId = kv.get(BARCODE_ID);
if (store == null || note == null || cardId == null) throw new InvalidObjectException("Not a valid import URI: " + uri.toString());
String unparsedBarcodeType = kv.get(BARCODE_TYPE);
if(unparsedBarcodeType != null && !unparsedBarcodeType.equals(""))
{
barcodeType = BarcodeFormat.valueOf(unparsedBarcodeType);
}
String unparsedBalance = uri.getQueryParameter(BALANCE);
String unparsedBalance = kv.get(BALANCE);
if(unparsedBalance != null && !unparsedBalance.equals(""))
{
balance = new BigDecimal(unparsedBalance);
}
String unparsedBalanceType = uri.getQueryParameter(BALANCE_TYPE);
String unparsedBalanceType = kv.get(BALANCE_TYPE);
if (unparsedBalanceType != null && !unparsedBalanceType.equals(""))
{
balanceType = Currency.getInstance(unparsedBalanceType);
}
String unparsedExpiry = uri.getQueryParameter(EXPIRY);
String unparsedExpiry = kv.get(EXPIRY);
if(unparsedExpiry != null && !unparsedExpiry.equals(""))
{
expiry = new Date(Long.parseLong(unparsedExpiry));
}
String unparsedHeaderColor = uri.getQueryParameter(HEADER_COLOR);
String unparsedHeaderColor = kv.get(HEADER_COLOR);
if(unparsedHeaderColor != null)
{
headerColor = Integer.parseInt(unparsedHeaderColor);
}
return new LoyaltyCard(-1, store, note, expiry, balance, balanceType, cardId, barcodeId, barcodeType, headerColor, 0);
} catch (NullPointerException | NumberFormatException ex) {
} catch (NullPointerException | NumberFormatException | UnsupportedEncodingException ex) {
throw new InvalidObjectException("Not a valid import URI");
}
}
private StringBuilder appendFragment(StringBuilder fragment, String key, String value) throws UnsupportedEncodingException {
if (fragment.length() > 0) {
fragment.append("&");
}
// Double-encode the value to make sure it can't accidentally contain symbols that'll break the parser
fragment.append(key).append("=").append(URLEncoder.encode(value, StandardCharsets.UTF_8.toString()));
return fragment;
}
// Protected for usage in tests
protected Uri toUri(LoyaltyCard loyaltyCard) {
protected Uri toUri(LoyaltyCard loyaltyCard) throws UnsupportedEncodingException {
Uri.Builder uriBuilder = new Uri.Builder();
uriBuilder.scheme("https");
uriBuilder.authority(host);
uriBuilder.path(path);
uriBuilder.appendQueryParameter(STORE, loyaltyCard.store);
uriBuilder.appendQueryParameter(NOTE, loyaltyCard.note);
uriBuilder.appendQueryParameter(BALANCE, loyaltyCard.balance.toString());
// Use fragment instead of QueryParameter to not leak this data to the server
StringBuilder fragment = new StringBuilder();
fragment = appendFragment(fragment, STORE, loyaltyCard.store);
fragment = appendFragment(fragment, NOTE, loyaltyCard.note);
fragment = appendFragment(fragment, BALANCE, loyaltyCard.balance.toString());
if (loyaltyCard.balanceType != null) {
uriBuilder.appendQueryParameter(BALANCE_TYPE, loyaltyCard.balanceType.getCurrencyCode());
fragment = appendFragment(fragment, BALANCE_TYPE, loyaltyCard.balanceType.getCurrencyCode());
}
if (loyaltyCard.expiry != null) {
uriBuilder.appendQueryParameter(EXPIRY, String.valueOf(loyaltyCard.expiry.getTime()));
fragment = appendFragment(fragment, EXPIRY, String.valueOf(loyaltyCard.expiry.getTime()));
}
uriBuilder.appendQueryParameter(CARD_ID, loyaltyCard.cardId);
fragment = appendFragment(fragment, CARD_ID, loyaltyCard.cardId);
if(loyaltyCard.barcodeId != null) {
uriBuilder.appendQueryParameter(BARCODE_ID, loyaltyCard.barcodeId);
fragment = appendFragment(fragment, BARCODE_ID, loyaltyCard.barcodeId);
}
if(loyaltyCard.barcodeType != null) {
uriBuilder.appendQueryParameter(BARCODE_TYPE, loyaltyCard.barcodeType.toString());
fragment = appendFragment(fragment, BARCODE_TYPE, loyaltyCard.barcodeType.toString());
}
if(loyaltyCard.headerColor != null) {
uriBuilder.appendQueryParameter(HEADER_COLOR, loyaltyCard.headerColor.toString());
fragment = appendFragment(fragment, HEADER_COLOR, loyaltyCard.headerColor.toString());
}
// Star status will not be exported
// Front and back pictures are often too big to fit into a message in base64 nicely, not sharing either...
uriBuilder.fragment(fragment.toString());
return uriBuilder.build();
}
public void startShareIntent(List<LoyaltyCard> loyaltyCards) {
public void startShareIntent(List<LoyaltyCard> loyaltyCards) throws UnsupportedEncodingException {
int loyaltyCardCount = loyaltyCards.size();
StringBuilder text = new StringBuilder();

View File

@@ -29,6 +29,7 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.google.zxing.BarcodeFormat;
import java.io.UnsupportedEncodingException;
import java.math.BigDecimal;
import java.text.DateFormat;
import java.util.Arrays;
@@ -562,7 +563,12 @@ public class LoyaltyCardViewActivity extends AppCompatActivity
break;
case R.id.action_share:
importURIHelper.startShareIntent(Arrays.asList(loyaltyCard));
try {
importURIHelper.startShareIntent(Arrays.asList(loyaltyCard));
} catch (UnsupportedEncodingException e) {
Toast.makeText(LoyaltyCardViewActivity.this, R.string.failedGeneratingShareURL, Toast.LENGTH_LONG).show();
e.printStackTrace();
}
return true;
case R.id.action_lock_unlock:

View File

@@ -22,6 +22,7 @@ import android.widget.Toast;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.google.android.material.tabs.TabLayout;
import java.io.UnsupportedEncodingException;
import java.util.List;
import androidx.appcompat.app.AppCompatActivity;
@@ -97,7 +98,12 @@ public class MainActivity extends AppCompatActivity implements LoyaltyCardCursor
else if (inputItem.getItemId() == R.id.action_share)
{
final ImportURIHelper importURIHelper = new ImportURIHelper(MainActivity.this);
importURIHelper.startShareIntent(mAdapter.getSelectedItems());
try {
importURIHelper.startShareIntent(mAdapter.getSelectedItems());
} catch (UnsupportedEncodingException e) {
Toast.makeText(MainActivity.this, R.string.failedGeneratingShareURL, Toast.LENGTH_LONG).show();
e.printStackTrace();
}
inputMode.finish();
return true;
}

View File

@@ -192,4 +192,5 @@
<string name="yes">Yes</string>
<string name="no">No</string>
<string name="passwordRequired">Please enter the password</string>
<string name="failedGeneratingShareURL">Failed generating share URL. Please report this bug!</string>
</resources>

View File

@@ -14,6 +14,7 @@ import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import java.io.InvalidObjectException;
import java.io.UnsupportedEncodingException;
import java.math.BigDecimal;
import java.util.Currency;
import java.util.Date;
@@ -37,12 +38,11 @@ public class ImportURITest {
}
@Test
public void ensureNoDataLoss() throws InvalidObjectException
{
public void ensureNoDataLoss() throws InvalidObjectException, UnsupportedEncodingException {
// Generate card
Date date = new Date();
db.insertLoyaltyCard("store", "note", date, new BigDecimal("100"), null, BarcodeFormat.UPC_E.toString(), BarcodeFormat.UPC_A.toString(), BarcodeFormat.QR_CODE, Color.BLACK, 1);
db.insertLoyaltyCard("store", "This note contains evil symbols like & and = that will break the parser if not escaped right $#!%()*+;:á", date, new BigDecimal("100"), null, BarcodeFormat.UPC_E.toString(), BarcodeFormat.UPC_A.toString(), BarcodeFormat.QR_CODE, Color.BLACK, 1);
// Get card
LoyaltyCard card = db.getLoyaltyCard(1);
@@ -68,8 +68,7 @@ public class ImportURITest {
}
@Test
public void ensureNoCrashOnMissingHeaderFields() throws InvalidObjectException
{
public void ensureNoCrashOnMissingHeaderFields() throws InvalidObjectException, UnsupportedEncodingException {
// Generate card
db.insertLoyaltyCard("store", "note", null, new BigDecimal("10.00"), Currency.getInstance("EUR"), BarcodeFormat.UPC_A.toString(), null, BarcodeFormat.QR_CODE, null, 0);
@@ -110,35 +109,46 @@ public class ImportURITest {
@Test
public void failToParseBadData()
{
try {
//"stare" instead of store
importURIHelper.parse(Uri.parse("https://brarcher.github.io/loyalty-card-locker/share?stare=store&note=note&cardid=12345&barcodetype=ITF&headercolor=-416706"));
assertTrue(false); // Shouldn't get here
} catch(InvalidObjectException ex) {
// Desired behaviour
String[] urls = new String[2];
urls[0] = "https://brarcher.github.io/loyalty-card-locker/share?stare=store&note=note&cardid=12345&barcodetype=ITF&headercolor=-416706";
urls[1] = "https://thelastproject.github.io/Catima/share#stare%3Dstore%26note%3Dnote%26balance%3D0%26cardid%3D12345%26barcodetype%3DITF%26headercolor%3D-416706";
for (String url : urls) {
try {
//"stare" instead of store
importURIHelper.parse(Uri.parse(url));
assertTrue(false); // Shouldn't get here
} catch (InvalidObjectException ex) {
// Desired behaviour
}
}
}
@Test
public void parseAdditionalUnforeseenData()
{
LoyaltyCard parsedCard = null;
try {
parsedCard = importURIHelper.parse(Uri.parse("https://brarcher.github.io/loyalty-card-locker/share?store=store&note=note&cardid=12345&barcodetype=ITF&headercolor=-416706&headertextcolor=-1&notforeseen=no"));
} catch (InvalidObjectException e) {
e.printStackTrace();
}
public void parseAdditionalUnforeseenData() {
String[] urls = new String[2];
urls[0] = "https://brarcher.github.io/loyalty-card-locker/share?store=store&note=note&cardid=12345&barcodetype=ITF&headercolor=-416706&headertextcolor=-1&notforeseen=no";
urls[1] = "https://thelastproject.github.io/Catima/share#store%3Dstore%26note%3Dnote%26balance%3D0%26cardid%3D12345%26barcodetype%3DITF%26headercolor%3D-416706%26notforeseen%3Dno";
// Compare everything
assertEquals("store", parsedCard.store);
assertEquals("note", parsedCard.note);
assertEquals(null, parsedCard.expiry);
assertEquals(new BigDecimal("0"), parsedCard.balance);
assertEquals(null, parsedCard.balanceType);
assertEquals("12345", parsedCard.cardId);
assertEquals(null, parsedCard.barcodeId);
assertEquals(BarcodeFormat.ITF, parsedCard.barcodeType);
assertEquals(Integer.valueOf(-416706), parsedCard.headerColor);
assertEquals(0, parsedCard.starStatus);
for (String url : urls) {
LoyaltyCard parsedCard = null;
try {
parsedCard = importURIHelper.parse(Uri.parse(url));
} catch (InvalidObjectException e) {
e.printStackTrace();
}
// Compare everything
assertEquals("store", parsedCard.store);
assertEquals("note", parsedCard.note);
assertEquals(null, parsedCard.expiry);
assertEquals(new BigDecimal("0"), parsedCard.balance);
assertEquals(null, parsedCard.balanceType);
assertEquals("12345", parsedCard.cardId);
assertEquals(null, parsedCard.barcodeId);
assertEquals(BarcodeFormat.ITF, parsedCard.barcodeType);
assertEquals(Integer.valueOf(-416706), parsedCard.headerColor);
assertEquals(0, parsedCard.starStatus);
}
}
}