Skip to content

Commit 28dbbcf

Browse files
committed
feat: implement secure storage for GitHub token using tiered approach with encryption. Support with Gnome keyring and Kwallet
1 parent 60178fa commit 28dbbcf

13 files changed

Lines changed: 556 additions & 18 deletions

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,12 @@ It also doesn't require FUSE to run, thanks to the [uruntime](https://github.com
2929
- **Simple uninstall**: Right click in app drawer and choose `Move to Trash`, can uninstall in AppManager or simply delete from `~/Applications` folder.
3030
- **Install registry + preferences**: Main window lists installed apps, default mode, and cleanup behaviors, all stored with GSettings.
3131
- **Background app updates**: Optional automatic update checks with configurable interval (daily, weekly, monthly) and notifications when updates are found.
32+
- **GitHub authentication**: Optionally store a GitHub personal access token to raise the API rate limit from 60 to 5,000 requests per hour. The token is kept in the system keyring (GNOME Keyring, KWallet, KeePassXC) when a Secret Service is available, and otherwise in an AES-256-GCM blob bound to the machine and user account, so a synced or copied config file is useless elsewhere.
3233

3334
## Requirements
3435

3536
- `valac`, `meson`, `ninja`
36-
- Libraries: `libadwaita-1` (>= 1.6), `gtk4`, `gio-2.0`, `glib-2.0`, `gmodule-2.0`, `json-glib-1.0`, `gee-0.8`, `libsoup-3.0`
37+
- Libraries: `libadwaita-1` (>= 1.6), `gtk4`, `gio-2.0`, `glib-2.0`, `gmodule-2.0`, `json-glib-1.0`, `gee-0.8`, `libsoup-3.0`, `libsecret-1`, `gnutls` (>= 3.6.13)
3738
- Runtime tools: `unsquashfs`, `dwarfsextract`
3839

3940
## Install

‎data/com.github.AppManager.gschema.xml‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,13 @@
8888
key>
8989
<key name="github-token" type="s">
9090
<default>''default>
91-
<summary>GitHub personal access tokensummary>
92-
<description>A GitHub personal access token used to authenticate API requests. This raises the rate limit from 60 to 5,000 requests per hour. Only needs read-only access to public repositories.description>
91+
<summary>GitHub personal access token (legacy, no longer written)summary>
92+
<description>Legacy plaintext GitHub token key. Kept only so a token stored by an older version can be migrated into the keyring or the encrypted key below, and for downgrade safety. New versions never write here. See TokenStore.description>
93+
key>
94+
<key name="github-token-cipher" type="s">
95+
<default>''default>
96+
<summary>Encrypted GitHub token (tier-2 fallback)summary>
97+
<description>Machine+user-bound AES-256-GCM blob holding the GitHub token, used only when no freedesktop Secret Service is available. Prefixed 'AMTC1:'. Useless off its origin machine. See TokenStore / crypto_shim.description>
9398
key>
9499
schema>
95100
schemalist>

‎meson.build‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,18 @@ gtk_dep = dependency('gtk4')
1414
json_glib_dep = dependency('json-glib-1.0')
1515
gee_dep = dependency('gee-0.8')
1616
libsoup_dep = dependency('libsoup-3.0')
17+
libsecret_dep = dependency('libsecret-1')
18+
# gnutls >=3.6.13 provides the HKDF API used by the tier-2 crypto shim.
19+
gnutls_dep = dependency('gnutls', version: '>=3.6.13')
1720

1821
add_project_arguments(['--target-glib=2.74', '--pkg=posix'], language: 'vala')
1922

23+
# Local vapi for the tier-2 crypto shim (GnuTLS has no shipped Vala binding).
24+
add_project_arguments(
25+
['--vapidir', meson.current_source_dir() / 'src' / 'vapi', '--pkg', 'crypto-shim'],
26+
language: 'vala'
27+
)
28+
2029
# Define GETTEXT_PACKAGE for C code (required by gi18n-lib.h)
2130
add_project_arguments(['-DGETTEXT_PACKAGE="@0@"'.format(meson.project_name())], language: 'c')
2231

@@ -76,6 +85,7 @@ core_sources = files(
7685
'src/core/installer.vala',
7786
'src/core/update_sources.vala',
7887
'src/core/tls_session.vala',
88+
'src/core/token_store.vala',
7989
'src/core/updater.vala',
8090
'src/core/update_log.vala',
8191
'src/core/background_update_service.vala',
@@ -96,6 +106,24 @@ build_info = configure_file(
96106

97107
core_sources += build_info
98108

109+
# Tier-2 crypto shim built as a C static library. Kept out of the Vala target's
110+
# `dependencies` so meson does not try to load a (non-existent) gnutls vapi;
111+
# gnutls links transitively through this library instead.
112+
crypto_shim_inc = include_directories('src/core')
113+
crypto_shim_lib = static_library('crypto-shim',
114+
'src/core/crypto_shim.c',
115+
dependencies: [glib_dep, gnutls_dep],
116+
include_directories: crypto_shim_inc
117+
)
118+
119+
crypto_shim_test = executable('crypto-shim-test',
120+
'tests/crypto_shim_test.c',
121+
link_with: crypto_shim_lib,
122+
include_directories: crypto_shim_inc,
123+
dependencies: [glib_dep]
124+
)
125+
test('crypto-shim', crypto_shim_test)
126+
99127
# Fetch and install DwarFS tools if requested and supported
100128
if bundle_dwarfs and dwarfs_arch != ''
101129
fetch_script = files('scripts/fetch-dwarfs-tools.sh')

‎package.nix‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
, json-glib
1515
, libgee
1616
, libsoup_3
17+
, libsecret
18+
, gnutls
1719
, zstd
1820
, squashfsTools
1921
, squashfuse
@@ -47,6 +49,8 @@ stdenv.mkDerivation(finalAttrs: {
4749
json-glib
4850
libgee
4951
libsoup_3
52+
libsecret
53+
gnutls
5054
zstd
5155
];
5256

‎scripts/make-anyimage.sh‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ pacman -Syu --noconfirm \
7878
json-glib \
7979
libgee \
8080
libsoup3 \
81+
libsecret \
82+
gnutls \
8183
gvfs \
8284
desktop-file-utils \
8385
squashfs-tools \

‎src/core/crypto_shim.c‎

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/*
2+
* Tier-2 fallback crypto for the stored GitHub token.
3+
*
4+
* See crypto_shim.h for the threat model. In short: AES-256-GCM with a key
5+
* derived (HKDF-SHA256) from the host machine-id + uid. machine-id is not a
6+
* secret, so this is hardening/obfuscation that defeats config exfiltration,
7+
* not protection against a same-user local process.
8+
*
9+
* All crypto is GnuTLS; GLib is used for allocation, base64, and file I/O so
10+
* the returned strings interoperate with Vala (which frees with g_free()).
11+
*/
12+
13+
#include "crypto_shim.h"
14+
15+
#include
16+
#include
17+
#include
18+
19+
#include
20+
#include
21+
22+
#define AM_BLOB_PREFIX "AMTC1:"
23+
#define AM_SALT_LEN 16
24+
#define AM_NONCE_LEN 12
25+
#define AM_TAG_LEN 16
26+
#define AM_KEY_LEN 32
27+
#define AM_HKDF_INFO "github-token-v1"
28+
29+
/* Read and trim the host machine-id. Returns a g_malloc'd string or NULL. */
30+
static char *
31+
read_machine_id (void)
32+
{
33+
const char *paths[] = { "/etc/machine-id", "/var/lib/dbus/machine-id" };
34+
35+
for (guint i = 0; i < G_N_ELEMENTS (paths); i++)
36+
{
37+
char *contents = NULL;
38+
gsize len = 0;
39+
40+
if (g_file_get_contents (paths[i], &contents, &len, NULL))
41+
{
42+
char *trimmed = g_strstrip (contents); /* trims in place */
43+
if (trimmed[0] != '\0')
44+
{
45+
char *id = g_strdup (trimmed);
46+
g_free (contents);
47+
return id;
48+
}
49+
g_free (contents);
50+
}
51+
}
52+
53+
return NULL;
54+
}
55+
56+
/*
57+
* Derive the 32-byte AES key from (machine-id : uid : app-id) and salt via
58+
* HKDF-SHA256 into key_out. Returns TRUE on success.
59+
*/
60+
static gboolean
61+
derive_key (const guint8 *salt, gsize salt_len, guint8 key_out[AM_KEY_LEN])
62+
{
63+
char *machine_id = read_machine_id ();
64+
if (machine_id == NULL)
65+
return FALSE;
66+
67+
char *ikm_str = g_strdup_printf ("%s:%u:com.github.AppManager",
68+
machine_id, (guint) getuid ());
69+
g_free (machine_id);
70+
71+
gnutls_datum_t ikm = { (unsigned char *) ikm_str, (unsigned int) strlen (ikm_str) };
72+
gnutls_datum_t salt_d = { (unsigned char *) salt, (unsigned int) salt_len };
73+
gnutls_datum_t info = { (unsigned char *) AM_HKDF_INFO, (unsigned int) strlen (AM_HKDF_INFO) };
74+
75+
guint8 prk[AM_KEY_LEN];
76+
int rc = gnutls_hkdf_extract (GNUTLS_MAC_SHA256, &ikm, &salt_d, prk);
77+
if (rc == 0)
78+
{
79+
gnutls_datum_t prk_d = { prk, AM_KEY_LEN };
80+
rc = gnutls_hkdf_expand (GNUTLS_MAC_SHA256, &prk_d, &info, key_out, AM_KEY_LEN);
81+
}
82+
83+
gnutls_memset (prk, 0, sizeof (prk));
84+
gnutls_memset (ikm_str, 0, strlen (ikm_str));
85+
g_free (ikm_str);
86+
87+
return rc == 0;
88+
}
89+
90+
char *
91+
am_crypto_encrypt (const char *plaintext)
92+
{
93+
if (plaintext == NULL)
94+
return NULL;
95+
96+
guint8 salt[AM_SALT_LEN];
97+
guint8 nonce[AM_NONCE_LEN];
98+
if (gnutls_rnd (GNUTLS_RND_KEY, salt, sizeof (salt)) != 0)
99+
return NULL;
100+
if (gnutls_rnd (GNUTLS_RND_KEY, nonce, sizeof (nonce)) != 0)
101+
return NULL;
102+
103+
guint8 key[AM_KEY_LEN];
104+
if (!derive_key (salt, sizeof (salt), key))
105+
return NULL;
106+
107+
gnutls_datum_t key_d = { key, AM_KEY_LEN };
108+
gnutls_aead_cipher_hd_t handle = NULL;
109+
if (gnutls_aead_cipher_init (&handle, GNUTLS_CIPHER_AES_256_GCM, &key_d) != 0)
110+
{
111+
gnutls_memset (key, 0, sizeof (key));
112+
return NULL;
113+
}
114+
115+
gsize pt_len = strlen (plaintext);
116+
gsize ct_cap = pt_len + AM_TAG_LEN;
117+
guint8 *ct = g_malloc (ct_cap);
118+
size_t ct_len = ct_cap;
119+
int rc = gnutls_aead_cipher_encrypt (handle,
120+
nonce, sizeof (nonce),
121+
NULL, 0,
122+
AM_TAG_LEN,
123+
plaintext, pt_len,
124+
ct, &ct_len);
125+
gnutls_aead_cipher_deinit (handle);
126+
gnutls_memset (key, 0, sizeof (key));
127+
128+
if (rc != 0)
129+
{
130+
g_free (ct);
131+
return NULL;
132+
}
133+
134+
/* payload = salt(16) || nonce(12) || ciphertext || tag(16) */
135+
gsize payload_len = AM_SALT_LEN + AM_NONCE_LEN + ct_len;
136+
guint8 *payload = g_malloc (payload_len);
137+
memcpy (payload, salt, AM_SALT_LEN);
138+
memcpy (payload + AM_SALT_LEN, nonce, AM_NONCE_LEN);
139+
memcpy (payload + AM_SALT_LEN + AM_NONCE_LEN, ct, ct_len);
140+
g_free (ct);
141+
142+
char *b64 = g_base64_encode (payload, payload_len);
143+
g_free (payload);
144+
145+
char *blob = g_strconcat (AM_BLOB_PREFIX, b64, NULL);
146+
g_free (b64);
147+
return blob;
148+
}
149+
150+
char *
151+
am_crypto_decrypt (const char *blob)
152+
{
153+
if (blob == NULL)
154+
return NULL;
155+
if (!g_str_has_prefix (blob, AM_BLOB_PREFIX))
156+
return NULL; /* version gate: reject anything but AMTC1: */
157+
158+
gsize payload_len = 0;
159+
guchar *payload = g_base64_decode (blob + strlen (AM_BLOB_PREFIX), &payload_len);
160+
if (payload == NULL
161+
|| payload_len < (gsize) (AM_SALT_LEN + AM_NONCE_LEN + AM_TAG_LEN))
162+
{
163+
g_free (payload);
164+
return NULL;
165+
}
166+
167+
const guint8 *salt = payload;
168+
const guint8 *nonce = payload + AM_SALT_LEN;
169+
const guint8 *ct = payload + AM_SALT_LEN + AM_NONCE_LEN;
170+
gsize ct_len = payload_len - AM_SALT_LEN - AM_NONCE_LEN;
171+
172+
guint8 key[AM_KEY_LEN];
173+
if (!derive_key (salt, AM_SALT_LEN, key))
174+
{
175+
g_free (payload);
176+
return NULL;
177+
}
178+
179+
gnutls_datum_t key_d = { key, AM_KEY_LEN };
180+
gnutls_aead_cipher_hd_t handle = NULL;
181+
if (gnutls_aead_cipher_init (&handle, GNUTLS_CIPHER_AES_256_GCM, &key_d) != 0)
182+
{
183+
gnutls_memset (key, 0, sizeof (key));
184+
g_free (payload);
185+
return NULL;
186+
}
187+
188+
gsize pt_cap = ct_len - AM_TAG_LEN;
189+
guint8 *pt = g_malloc (pt_cap + 1); /* +1 for the NUL terminator */
190+
size_t pt_len = pt_cap;
191+
int rc = gnutls_aead_cipher_decrypt (handle,
192+
nonce, AM_NONCE_LEN,
193+
NULL, 0,
194+
AM_TAG_LEN,
195+
ct, ct_len,
196+
pt, &pt_len);
197+
gnutls_aead_cipher_deinit (handle);
198+
gnutls_memset (key, 0, sizeof (key));
199+
g_free (payload);
200+
201+
if (rc != 0) /* tamper, truncation, or wrong machine/user -> tag mismatch */
202+
{
203+
gnutls_memset (pt, 0, pt_cap);
204+
g_free (pt);
205+
return NULL;
206+
}
207+
208+
pt[pt_len] = '\0';
209+
char *result = g_strndup ((const char *) pt, pt_len);
210+
gnutls_memset (pt, 0, pt_cap + 1);
211+
g_free (pt);
212+
return result;
213+
}

‎src/core/crypto_shim.h‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#ifndef AM_CRYPTO_SHIM_H
2+
#define AM_CRYPTO_SHIM_H
3+
4+
/*
5+
* Tier-2 fallback crypto for the stored GitHub token (see token_store.vala).
6+
*
7+
* IMPORTANT: this is machine+user-bound obfuscation, NOT strong secrecy. The
8+
* AES key is derived from /etc/machine-id (which is not a secret) plus the
9+
* current uid. That makes a config file which is synced to the cloud, backed
10+
* up, or copied to another machine/account useless off its origin machine --
11+
* the realistic leak path. It does NOT protect against a process running as
12+
* the same user on the same machine: such a process can re-derive the exact
13+
* same key we do. Nothing user-space can prevent that.
14+
*
15+
* Both functions return a newly-allocated, NUL-terminated string that the
16+
* caller owns and must release with g_free(). A NULL return means failure:
17+
* missing machine-id (tier 2 unavailable), RNG failure, or -- for decrypt --
18+
* a wrong prefix, a truncated blob, tampering, or a wrong machine/user.
19+
*/
20+
21+
char *am_crypto_encrypt (const char *plaintext);
22+
char *am_crypto_decrypt (const char *blob);
23+
24+
#endif /* AM_CRYPTO_SHIM_H */

0 commit comments

Comments
 (0)