Skip to content

Commit 6973037

Browse files
1.1.2: safety and security fixes from an audit
Never show an old or wrong number as current: - The widget hides the value after 5 minutes without a new reading, and clears itself when there are none (the launcher kept the old view). - The home screen clears too, instead of keeping the last value. - Notification and widgets are redrawn every minute and on screen-on from memory, and each fetch has a 30 s overall deadline. - Stale readings: no delta, and the plain drop instead of the number in the status bar. - Nightscout LOW (39) and HIGH (401) codes shown as LOW/HIGH, no trend. - Thresholds must be plausible, catching typos and unit mix-ups. - Readings are tied to their source: a new address or turning fake readings off drops the old ones instead of showing them unlabelled. Security: - Plain http only with a token, never the API secret; localhost refused. - The saved secret is never shown again and only kept for the same site. - Addresses are cleaned; credentials pasted inside them are moved out. - The notification shows a generic error, never one naming the site. - The widget ignores ids in the broadcast, catches errors per widget and clamps sizes; the network task catches Throwable. - Empty taskAffinity, no autofill on the settings fields, guarded battery-optimisation request, "Example" label on a made-up preview. - Quiet notification channel (default importance, no sound, light or badge); with the screen off it is only reposted when the value, arrow or stale state changes, which stops HyperOS lighting the edges. Repository: official Gradle 8.14.3 wrapper with distributionSha256Sum, CI with read-only token, SHA-pinned actions and wrapper validation, signing certificate fingerprint in the README, debug APK no longer published. The settings preview follows the fixed scale as it is typed (Reddit). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 60d9245 commit 6973037

22 files changed

Lines changed: 461 additions & 99 deletions

File tree

‎.github/workflows/build.yml‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,29 @@ on:
44
push:
55
pull_request:
66

7+
# Read-only token: this workflow builds and tests, it never writes to the repo
8+
permissions:
9+
contents: read
10+
711
jobs:
812
build:
913
runs-on: ubuntu-latest
1014
steps:
11-
- uses: actions/checkout@v4
12-
- uses: actions/setup-java@v4
15+
# Actions pinned to full commit SHAs (tag in the comment): a moved tag cannot change what runs here
16+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
17+
with:
18+
persist-credentials: false
19+
# Fails if gradle-wrapper.jar is not an official Gradle one, so a PR cannot slip in a tampered wrapper
20+
- uses: gradle/actions/wrapper-validation@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
21+
- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
1322
with:
1423
distribution: temurin
1524
java-version: 17
1625
- run: chmod +x gradlew
1726
- run: ./gradlew test assembleDebug
18-
- uses: actions/upload-artifact@v4
27+
# Only for the repo's own pushes: a pull request's build is never offered as a download here
28+
- if: github.event_name == 'push'
29+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
1930
with:
2031
name: sugarglass-debug
2132
path: app/build/outputs/apk/debug/app-debug.apk

‎README.md‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ What it deliberately does **not** do: alarms, treatments, uploading, statistics.
3333

3434
## Install
3535

36-
1. On your phone, [download the APK](https://github.com/federicomameli1/sugarglass/releases/latest/download/sugarglass.apk) and open it. Android will ask you to **allow installing apps** from your browser or file manager the first time: allow it, then tap **Install**. If Google Play Protect warns that the app comes from an unknown developer, that is because it isn't on the Play Store: tap **More details → Install anyway**. Updates install the same way, over the old version, and keep your settings.
37-
2. Open Sugarglass, enter your Nightscout address and, if your site is not public, a token (recommended, with the `readable` role) or your API secret. Tap **Save and start**. A Nightscout on your home network (`http://192.168.…`) works too.
36+
1. On your phone, [download the APK](https://github.com/federicomameli1/sugarglass/releases/latest/download/sugarglass.apk) and open it. Android will ask you to **allow installing apps** from your browser or file manager the first time: allow it, then tap **Install**. If Google Play Protect warns that the app comes from an unknown developer, that is because it isn't on the Play Store: tap **More details → Install anyway**. Updates install the same way, over the old version, and keep your settings. If an update ever refuses to install over the app you have ("package conflicts"), **don't uninstall**: it may not be a genuine build. Check it as described under [Is it the real one?](#is-it-the-real-one) and open an issue.
37+
2. Open Sugarglass, enter your Nightscout address and, if your site is not public, a token (recommended, with the `readable` role) or your API secret. Tap **Save and start**. A Nightscout on your home network (`http://192.168.…`) works too, with a token rather than the API secret.
3838
3. Allow notifications and let it **ignore battery optimisation**: without that Android may stop it.
3939
4. Long-press the home screen and add the Sugarglass widget.
4040

@@ -53,13 +53,24 @@ Tested on HyperOS only so far; if the steps for your phone differ, please open a
5353

5454
Sugarglass talks to your Nightscout site and nothing else. Your address and token stay on your phone. No analytics, no ads, no account.
5555

56-
- Your token only travels encrypted: plain `http://` is accepted just for addresses on your home network (like `192.168.x.x` or `nightscout.local`), everything else needs `https://`.
56+
- Your credentials only travel encrypted: plain `http://` is accepted just for addresses on your home network (like `192.168.x.x` or `nightscout.local`), and there only with a `readable` token, never the API secret. Everything else needs `https://`. Addresses on the phone itself (`localhost`) are refused, since any app on it could pretend to be Nightscout.
57+
- The saved token is never shown again, and stays with the site it was given to: change the address and you type it again.
5758
- If your site redirects somewhere else, Sugarglass stops and tells you the new address instead of following it with your credentials.
5859
- The settings are left out of Android's cloud backups and phone-to-phone transfers, so the token never leaves the phone; on a new phone you enter it again.
5960
- Prefer a token with only the `readable` role over the API secret: it can read your data but not change anything.
6061

6162
Permissions: internet (Nightscout), notifications, foreground service (to stay alive), start at boot, and the request to be excluded from battery optimisation.
6263

64+
## Is it the real one?
65+
66+
Every Sugarglass release is signed with the same key. Its certificate's SHA-256 fingerprint is:
67+
68+
```
69+
0C:56:17:BC:89:17:34:81:82:9D:EF:19:A9:E8:EC:06:09:C0:D3:92:84:D4:06:4F:25:78:0C:DB:C1:45:1A:86
70+
```
71+
72+
Check a downloaded APK with `apksigner verify --print-certs sugarglass.apk`, or on the phone with an app like AppVerifier. If it doesn't match, don't install it.
73+
6374
## Not a medical device
6475

6576
Sugarglass is a hobby project and is not a medical device. It can show old, wrong or missing data. **Do not use it to make treatment decisions**; always check your CGM app or a meter.
@@ -72,7 +83,7 @@ JDK 17 and the Android SDK (platform 34).
7283
./gradlew test assembleDebug
7384
```
7485

75-
The debug build installs next to the real app as **Sugarglass debug**, and its settings have a Debug page with fake readings: pick any value, trend or a stale reading and see the widget, notification and status bar with it. While fake readings are on, everything showing them says **DEMO**. A prebuilt one is attached to each release.
86+
The debug build installs next to the real app as **Sugarglass debug**, and its settings have a Debug page with fake readings: pick any value, trend or a stale reading and see the widget, notification and status bar with it. While fake readings are on, everything showing them says **DEMO**. It is not published: it is debuggable and signed with a development key, so build it yourself (`./gradlew assembleDebug`) or take the one from the latest CI run.
7687

7788
Release builds are signed with a key kept outside the repo: create `keystore.properties` in the project root with `storeFile`, `storePassword`, `keyAlias` and `keyPassword`, then `./gradlew assembleRelease`.
7889

‎app/build.gradle‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ android {
1414
applicationId 'io.github.federicomameli1.sugarglass'
1515
minSdk 26
1616
targetSdk 34
17-
versionCode 4
18-
versionName '1.1.1'
17+
versionCode 5
18+
versionName '1.1.2'
1919
}
2020

2121
signingConfigs {

‎app/src/main/AndroidManifest.xml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
<application
1212
android:allowBackup="false"
13+
android:taskAffinity=""
1314
android:dataExtractionRules="@xml/data_extraction_rules"
1415
android:fullBackupContent="false"
1516
android:icon="@mipmap/ic_launcher"

‎app/src/main/java/io/github/federicomameli1/sugarglass/Glucose.java‎

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ final class Glucose {
1010

1111
static final long WINDOW_MS = 3 * 60 * 60_000L; // fetched from Nightscout and drawn
1212
static final long STALE_MS = 15 * 60_000L;
13+
static final long HIDE_MS = 5 * 60_000L; // the widget stops showing a value this long without a new one
14+
static final int LOW = 39, HIGH = 401; // Nightscout's codes for below and above the sensor's range
1315
static final long GAP_MS = 10 * 60_000L; // the graph line breaks across longer gaps
1416
static final int MIN_SPAN = 80; // mg/dl, floor on the graph's vertical range
1517
static final double MGDL_PER_MMOL = 18.0182;
@@ -44,8 +46,18 @@ static final class Config {
4446
this.urgentHigh = urgentHigh;
4547
}
4648

49+
/**
50+
* In order, and each in a plausible band: a dropped or extra digit, or mg/dl values typed with
51+
* mmol/L selected, would otherwise silently turn the urgent colours off.
52+
*/
4753
boolean valid() {
48-
return 0 < urgentLow && urgentLow < low && low < high && high < urgentHigh;
54+
return urgentLow < low && low < high && high < urgentHigh
55+
&& between(urgentLow, 40, 90) && between(low, 55, 120)
56+
&& between(high, 120, 300) && between(urgentHigh, 150, 400);
57+
}
58+
59+
private static boolean between(int v, int min, int max) {
60+
return v >= min && v <= max;
4961
}
5062
}
5163

@@ -66,6 +78,8 @@ static String arrow(String direction) {
6678
}
6779

6880
static String value(int mgdl, Config c) {
81+
if (mgdl <= LOW) return "LOW"; // below what the sensor reads: not a measured 39
82+
if (mgdl >= HIGH) return "HIGH";
6983
return c.mmol ? String.format(Locale.getDefault(), "%.1f", mgdl / MGDL_PER_MMOL) : String.valueOf(mgdl);
7084
}
7185

@@ -80,7 +94,7 @@ static String delta(List<Reading> newestFirst, Config c) {
8094
past = r;
8195
if (ago >= 5 * 60_000) break;
8296
}
83-
if (past == null) return "";
97+
if (past == null || clamped(now) || clamped(past)) return ""; // LOW/HIGH have no real slope
8498
double mgdl = (now.sgv - past.sgv) * 5 * 60_000.0 / (now.date - past.date);
8599
if (!c.mmol) {
86100
long d = Math.round(mgdl);
@@ -90,6 +104,10 @@ static String delta(List<Reading> newestFirst, Config c) {
90104
return (tenths > 0 ? "+" : "") + String.format(Locale.getDefault(), "%.1f", tenths / 10.0);
91105
}
92106

107+
private static boolean clamped(Reading r) {
108+
return r.sgv <= LOW || r.sgv >= HIGH;
109+
}
110+
93111
static long minutesAgo(Reading r, long now) {
94112
return Math.max(0, (now - r.date) / 60_000);
95113
}
@@ -113,7 +131,8 @@ static int[] scale(List<Reading> r, Config c, int[] fixed) {
113131

114132
/**
115133
* Whether the app will send credentials to this Nightscout address: https anywhere, plain http only
116-
* on the home network, where nothing crosses the internet unencrypted.
134+
* on the home network, where nothing crosses the internet unencrypted. Not the phone itself: any app
135+
* on it could listen on a local port and serve made-up readings.
117136
*/
118137
static boolean safeAddress(String url) {
119138
URI u;
@@ -126,10 +145,72 @@ static boolean safeAddress(String url) {
126145
String scheme = u.getScheme().toLowerCase(Locale.ROOT), host = u.getHost().toLowerCase(Locale.ROOT);
127146
if (scheme.equals("https")) return true;
128147
if (!scheme.equals("http")) return false;
129-
return host.equals("localhost") || host.endsWith(".local") || host.endsWith(".lan")
130-
|| host.endsWith(".home.arpa") || privateIPv4(host)
131-
// IPv6: unique local (fc00::/7), link-local and loopback
132-
|| host.startsWith("[fc") || host.startsWith("[fd") || host.startsWith("[fe80") || host.equals("[::1]");
148+
return host.endsWith(".local") || host.endsWith(".lan") || host.endsWith(".home.arpa") || privateIPv4(host)
149+
// IPv6: unique local (fc00::/7) and link-local
150+
|| host.startsWith("[fc") || host.startsWith("[fd") || host.startsWith("[fe80");
151+
}
152+
153+
/** Nightscout access tokens look like "name-0123456789abcdef"; anything else is the API secret. */
154+
static boolean isToken(String secret) {
155+
return secret.matches("[\\w-]+-[0-9a-f]{16}");
156+
}
157+
158+
/**
159+
* Whether this secret may go to this address. Over plain http only a token (give it the read-only
160+
* role) or nothing: the API secret is admin access, and even a home network can be someone else's
161+
* when the phone joins a hotel or café Wi-Fi with the same addresses.
162+
*/
163+
static boolean secretAllowed(String url, String secret) {
164+
return secret.isEmpty() || isToken(secret) || url.toLowerCase(Locale.ROOT).startsWith("https://");
165+
}
166+
167+
/** An address as typed, split into a clean site address and any credential that was inside it. */
168+
static final class Address {
169+
final String url, secret;
170+
171+
Address(String url, String secret) {
172+
this.url = url;
173+
this.secret = secret;
174+
}
175+
}
176+
177+
/**
178+
* Cleans a typed or pasted address: https:// when no scheme is given, the credential moved out of
179+
* "https://SECRET@site" or "?token=", query and fragment dropped, a trailing /api/v1 removed.
180+
* Returns null when it is not an address at all.
181+
*/
182+
static Address cleanAddress(String typed) {
183+
String s = typed.trim();
184+
if (s.isEmpty()) return new Address("", "");
185+
if (!s.contains("://")) s = "https://" + s;
186+
URI u;
187+
try {
188+
u = new URI(s);
189+
} catch (URISyntaxException e) {
190+
return null;
191+
}
192+
if (u.getScheme() == null || u.getHost() == null) return null;
193+
String secret = u.getUserInfo() == null ? "" : u.getUserInfo();
194+
if (u.getQuery() != null) {
195+
for (String part : u.getQuery().split("&")) {
196+
if (part.startsWith("token=")) secret = part.substring(6);
197+
}
198+
}
199+
String path = u.getPath() == null ? "" : u.getPath().replaceAll("/+$", "");
200+
if (path.toLowerCase(Locale.ROOT).endsWith("/api/v1")) path = path.substring(0, path.length() - 7);
201+
String url = u.getScheme().toLowerCase(Locale.ROOT) + "://" + u.getHost()
202+
+ (u.getPort() == -1 ? "" : ":" + u.getPort()) + path;
203+
return new Address(url, secret);
204+
}
205+
206+
/** Scheme, host and port: the stored secret only stays with the site it was given to. */
207+
static String origin(String url) {
208+
try {
209+
URI u = new URI(url);
210+
return (u.getScheme() + "://" + u.getHost() + ":" + u.getPort()).toLowerCase(Locale.ROOT);
211+
} catch (URISyntaxException e) {
212+
return "";
213+
}
133214
}
134215

135216
private static boolean privateIPv4(String host) {
@@ -141,14 +222,19 @@ private static boolean privateIPv4(String host) {
141222
o[i] = Integer.parseInt(parts[i]);
142223
if (o[i] > 255) return false;
143224
}
144-
return o[0] == 10 || o[0] == 127 || (o[0] == 172 && o[1] >= 16 && o[1] <= 31)
225+
return o[0] == 10 || (o[0] == 172 && o[1] >= 16 && o[1] <= 31)
145226
|| (o[0] == 192 && o[1] == 168) || (o[0] == 169 && o[1] == 254);
146227
}
147228

148229
static boolean stale(Reading r, long now) {
149230
return now - r.date > STALE_MS;
150231
}
151232

233+
/** The widget hides the value once no new reading has come for a while, until one does. */
234+
static boolean hidden(Reading r, long now) {
235+
return now - r.date > HIDE_MS;
236+
}
237+
152238
static int band(int sgv, Config c) {
153239
if (sgv < c.urgentLow || sgv > c.urgentHigh) return URGENT;
154240
if (sgv < c.low || sgv > c.high) return OUT_OF_RANGE;

0 commit comments

Comments
 (0)