335 lines
11 KiB
Java
335 lines
11 KiB
Java
package org.julianfamily.keepassgo;
|
|
|
|
import android.content.Context;
|
|
import android.util.JsonReader;
|
|
import android.util.Log;
|
|
|
|
import java.io.File;
|
|
import java.io.FileInputStream;
|
|
import java.io.IOException;
|
|
import java.io.InputStreamReader;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
import java.util.Locale;
|
|
|
|
final class AutofillCacheStore {
|
|
private static final String TAG = "KeePassGOAutofill";
|
|
|
|
private AutofillCacheStore() {
|
|
}
|
|
|
|
static Entry findBestMatch(Context context, String webDomain) {
|
|
File cacheFile = findCacheFile(context);
|
|
if (cacheFile == null) {
|
|
Log.i(TAG, "autofill cache file not found");
|
|
return null;
|
|
}
|
|
List<Entry> entries;
|
|
try {
|
|
entries = readEntries(cacheFile);
|
|
} catch (IOException err) {
|
|
Log.e(TAG, "failed to read autofill cache", err);
|
|
return null;
|
|
}
|
|
if (entries.isEmpty()) {
|
|
return null;
|
|
}
|
|
NormalizedTarget target = normalizeURL(webDomain);
|
|
if (target.host.isEmpty()) {
|
|
return null;
|
|
}
|
|
List<Entry> exactHost = new ArrayList<>();
|
|
List<Entry> parentHost = new ArrayList<>();
|
|
for (Entry entry : entries) {
|
|
if (entryMatchesHost(entry, target.host)) {
|
|
exactHost.add(entry);
|
|
continue;
|
|
}
|
|
if (entryMatchesParentHost(entry, target.host)) {
|
|
parentHost.add(entry);
|
|
}
|
|
}
|
|
Entry matched = chooseEntry(target, exactHost);
|
|
if (matched != null) {
|
|
return matched;
|
|
}
|
|
return chooseEntry(target, parentHost);
|
|
}
|
|
|
|
private static File findCacheFile(Context context) {
|
|
List<File> candidates = new ArrayList<>();
|
|
File filesDir = context.getFilesDir();
|
|
if (filesDir != null) {
|
|
candidates.add(new File(filesDir, "keepassgo/autofill-cache.json"));
|
|
candidates.add(new File(filesDir, ".config/keepassgo/autofill-cache.json"));
|
|
candidates.add(new File(filesDir, "config/keepassgo/autofill-cache.json"));
|
|
}
|
|
File baseDir = context.getDataDir();
|
|
if (baseDir != null) {
|
|
candidates.add(new File(baseDir, "files/keepassgo/autofill-cache.json"));
|
|
candidates.add(new File(baseDir, "files/.config/keepassgo/autofill-cache.json"));
|
|
candidates.add(new File(baseDir, "files/config/keepassgo/autofill-cache.json"));
|
|
}
|
|
for (File candidate : candidates) {
|
|
if (candidate.isFile()) {
|
|
Log.i(TAG, "using autofill cache " + candidate.getAbsolutePath());
|
|
return candidate;
|
|
}
|
|
}
|
|
Log.i(TAG, "no autofill cache file in " + candidates);
|
|
return null;
|
|
}
|
|
|
|
private static List<Entry> readEntries(File cacheFile) throws IOException {
|
|
List<Entry> entries = new ArrayList<>();
|
|
try (JsonReader reader = new JsonReader(new InputStreamReader(new FileInputStream(cacheFile), StandardCharsets.UTF_8))) {
|
|
reader.beginObject();
|
|
while (reader.hasNext()) {
|
|
String name = reader.nextName();
|
|
if ("entries".equals(name)) {
|
|
reader.beginArray();
|
|
while (reader.hasNext()) {
|
|
entries.add(readEntry(reader));
|
|
}
|
|
reader.endArray();
|
|
} else {
|
|
reader.skipValue();
|
|
}
|
|
}
|
|
reader.endObject();
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
private static Entry readEntry(JsonReader reader) throws IOException {
|
|
String title = "";
|
|
String username = "";
|
|
String password = "";
|
|
String host = "";
|
|
String url = "";
|
|
List<String> targets = new ArrayList<>();
|
|
reader.beginObject();
|
|
while (reader.hasNext()) {
|
|
String name = reader.nextName();
|
|
switch (name) {
|
|
case "title":
|
|
title = nextString(reader);
|
|
break;
|
|
case "username":
|
|
username = nextString(reader);
|
|
break;
|
|
case "password":
|
|
password = nextString(reader);
|
|
break;
|
|
case "url":
|
|
url = nextString(reader);
|
|
break;
|
|
case "host":
|
|
host = normalizeHost(nextString(reader));
|
|
break;
|
|
case "targets":
|
|
reader.beginArray();
|
|
while (reader.hasNext()) {
|
|
targets.add(nextString(reader));
|
|
}
|
|
reader.endArray();
|
|
break;
|
|
default:
|
|
reader.skipValue();
|
|
break;
|
|
}
|
|
}
|
|
reader.endObject();
|
|
return new Entry(title, username, password, host, url, targets);
|
|
}
|
|
|
|
private static String nextString(JsonReader reader) throws IOException {
|
|
if (reader.peek() == android.util.JsonToken.NULL) {
|
|
reader.nextNull();
|
|
return "";
|
|
}
|
|
return reader.nextString();
|
|
}
|
|
|
|
private static String normalizeHost(String raw) {
|
|
return normalizeURL(raw).host;
|
|
}
|
|
|
|
private static NormalizedTarget normalizeURL(String raw) {
|
|
if (raw == null) {
|
|
return new NormalizedTarget("", "", "");
|
|
}
|
|
String value = raw.trim().toLowerCase(Locale.US);
|
|
if (value.startsWith("http://")) {
|
|
value = value.substring("http://".length());
|
|
} else if (value.startsWith("https://")) {
|
|
value = value.substring("https://".length());
|
|
}
|
|
int slash = value.indexOf('/');
|
|
if (slash >= 0) {
|
|
value = value.substring(0, slash);
|
|
}
|
|
int colon = value.indexOf(':');
|
|
if (colon >= 0) {
|
|
value = value.substring(0, colon);
|
|
}
|
|
String host = value;
|
|
String path = "/";
|
|
int schemeSep = raw.indexOf("://");
|
|
String original = raw.trim();
|
|
if (schemeSep < 0) {
|
|
original = "https://" + original;
|
|
}
|
|
try {
|
|
java.net.URI uri = java.net.URI.create(original);
|
|
if (uri.getHost() != null) {
|
|
host = uri.getHost().toLowerCase(Locale.US);
|
|
}
|
|
path = cleanPath(uri.getPath());
|
|
} catch (IllegalArgumentException ignored) {
|
|
path = "/";
|
|
}
|
|
return new NormalizedTarget(host, path, host + path);
|
|
}
|
|
|
|
private static String cleanPath(String raw) {
|
|
if (raw == null || raw.trim().isEmpty() || "/".equals(raw.trim())) {
|
|
return "/";
|
|
}
|
|
String value = raw.trim();
|
|
while (value.endsWith("/") && value.length() > 1) {
|
|
value = value.substring(0, value.length() - 1);
|
|
}
|
|
if (!value.startsWith("/")) {
|
|
value = "/" + value;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
private static Entry chooseEntry(NormalizedTarget target, List<Entry> entries) {
|
|
if (entries.isEmpty()) {
|
|
return null;
|
|
}
|
|
if (entries.size() == 1) {
|
|
return entries.get(0);
|
|
}
|
|
|
|
List<Entry> exact = new ArrayList<>();
|
|
List<Entry> prefix = new ArrayList<>();
|
|
int bestPrefixLen = -1;
|
|
for (Entry entry : entries) {
|
|
MatchQuality quality = bestTargetMatch(entry, target);
|
|
if (quality.exact) {
|
|
exact.add(entry);
|
|
continue;
|
|
}
|
|
if (quality.prefixLength <= 0) {
|
|
continue;
|
|
}
|
|
if (quality.prefixLength > bestPrefixLen) {
|
|
prefix.clear();
|
|
prefix.add(entry);
|
|
bestPrefixLen = quality.prefixLength;
|
|
} else if (quality.prefixLength == bestPrefixLen) {
|
|
prefix.add(entry);
|
|
}
|
|
}
|
|
if (exact.size() == 1) {
|
|
return exact.get(0);
|
|
}
|
|
if (exact.size() > 1 || prefix.isEmpty()) {
|
|
return null;
|
|
}
|
|
|
|
return prefix.size() == 1 ? prefix.get(0) : null;
|
|
}
|
|
|
|
private static boolean entryMatchesHost(Entry entry, String host) {
|
|
for (NormalizedTarget target : entryTargets(entry)) {
|
|
if (target.host.equals(host)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static boolean entryMatchesParentHost(Entry entry, String host) {
|
|
for (NormalizedTarget target : entryTargets(entry)) {
|
|
if (!target.host.isEmpty() && host.endsWith("." + target.host)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static List<NormalizedTarget> entryTargets(Entry entry) {
|
|
List<String> rawTargets = entry.targets;
|
|
if (rawTargets.isEmpty()) {
|
|
rawTargets = new ArrayList<>();
|
|
rawTargets.add(entry.url);
|
|
}
|
|
List<NormalizedTarget> targets = new ArrayList<>();
|
|
for (String rawTarget : rawTargets) {
|
|
NormalizedTarget target = normalizeURL(rawTarget);
|
|
if (!target.host.isEmpty()) {
|
|
targets.add(target);
|
|
}
|
|
}
|
|
return targets;
|
|
}
|
|
|
|
private static MatchQuality bestTargetMatch(Entry entry, NormalizedTarget target) {
|
|
int bestPrefixLen = -1;
|
|
for (NormalizedTarget entryTarget : entryTargets(entry)) {
|
|
if (entryTarget.url.equals(target.url)) {
|
|
return new MatchQuality(true, 0);
|
|
}
|
|
if (!"/".equals(entryTarget.path) && target.path.startsWith(entryTarget.path)) {
|
|
bestPrefixLen = Math.max(bestPrefixLen, entryTarget.path.length());
|
|
}
|
|
}
|
|
return new MatchQuality(false, bestPrefixLen);
|
|
}
|
|
|
|
static final class Entry {
|
|
final String title;
|
|
final String username;
|
|
final String password;
|
|
final String host;
|
|
final String url;
|
|
final List<String> targets;
|
|
|
|
Entry(String title, String username, String password, String host, String url, List<String> targets) {
|
|
this.title = title;
|
|
this.username = username;
|
|
this.password = password;
|
|
this.host = host;
|
|
this.url = url;
|
|
this.targets = new ArrayList<>(targets);
|
|
}
|
|
}
|
|
|
|
private static final class MatchQuality {
|
|
final boolean exact;
|
|
final int prefixLength;
|
|
|
|
MatchQuality(boolean exact, int prefixLength) {
|
|
this.exact = exact;
|
|
this.prefixLength = prefixLength;
|
|
}
|
|
}
|
|
|
|
private static final class NormalizedTarget {
|
|
final String host;
|
|
final String path;
|
|
final String url;
|
|
|
|
NormalizedTarget(String host, String path, String url) {
|
|
this.host = host;
|
|
this.path = path;
|
|
this.url = url;
|
|
}
|
|
}
|
|
}
|