Line data Source code
1 : /* SPDX-License-Identifier: GPL-3.0-or-later */
2 : /* Copyright 2026 Peter Csaszar */
3 :
4 : /**
5 : * @file app/credentials.c
6 : * @brief Env + INI-file api_id/api_hash loader.
7 : *
8 : * The INI parser tolerates (US-33):
9 : * - CRLF line endings (\r stripped in trim).
10 : * - UTF-8 BOM (EF BB BF) at file start.
11 : * - Comment lines starting with `#` or `;` (ignored entirely).
12 : * - Leading/trailing whitespace around both key and value.
13 : * - Double-quoted values: api_hash="abcdef..." → quotes stripped.
14 : * - Duplicate keys: last occurrence wins, one LOG_WARN per duplicate.
15 : * - Empty values (`api_id=`) → treated as missing, targeted diagnostic.
16 : *
17 : * Partial credentials produce explicit diagnostics pointing at the wizard:
18 : * - Only api_id missing → "api_id not found ..."
19 : * - Only api_hash missing → "api_hash not found ..."
20 : * - Both missing → combined message (legacy wording).
21 : *
22 : * api_hash must be a 32-char lowercase hex string; any other length is
23 : * rejected with a dedicated LOG_ERROR so a truncated copy-paste never
24 : * silently becomes the production credential.
25 : */
26 :
27 : #include "app/credentials.h"
28 :
29 : #include "logger.h"
30 : #include "platform/path.h"
31 : #include "raii.h"
32 :
33 : #include <ctype.h>
34 : #include <stdio.h>
35 : #include <stdlib.h>
36 : #include <string.h>
37 :
38 : #define API_HASH_MAX 64
39 : #define API_HASH_EXPECT 32 /* Telegram api_hash is 32 lowercase hex chars. */
40 :
41 : static char g_api_hash_buf[API_HASH_MAX + 1];
42 :
43 : /** Strip trailing whitespace / CR / LF in place. */
44 46 : static void rtrim_inplace(char *s) {
45 46 : size_t n = strlen(s);
46 102 : while (n > 0 && (s[n - 1] == '\n' || s[n - 1] == '\r' ||
47 50 : s[n - 1] == ' ' || s[n - 1] == '\t')) {
48 56 : s[--n] = '\0';
49 : }
50 46 : }
51 :
52 : /** Strip one pair of matched double quotes surrounding @p s, in place. */
53 46 : static void unquote_inplace(char *s) {
54 46 : size_t n = strlen(s);
55 46 : if (n >= 2 && s[0] == '"' && s[n - 1] == '"') {
56 2 : memmove(s, s + 1, n - 2);
57 2 : s[n - 2] = '\0';
58 : }
59 46 : }
60 :
61 : /**
62 : * Reads the value of @p key from the INI at @p path into @p out.
63 : *
64 : * @return 0 on success (value copied),
65 : * -1 if the file cannot be opened,
66 : * -2 if the key is not present,
67 : * -3 if the key exists but the value is empty.
68 : *
69 : * Emits LOG_WARN if the key appears more than once; the last occurrence
70 : * wins.
71 : */
72 56 : static int read_ini_key(const char *path, const char *key,
73 : char *out, size_t cap) {
74 112 : RAII_FILE FILE *fp = fopen(path, "r");
75 56 : if (!fp) return -1;
76 :
77 : char line[512];
78 52 : size_t klen = strlen(key);
79 52 : int found = 0;
80 52 : int empty_value = 0;
81 :
82 : /* UTF-8 BOM (EF BB BF) at file start is consumed on the first read so
83 : * that the first key on line 1 is still recognised. */
84 52 : int first_line = 1;
85 :
86 160 : while (fgets(line, sizeof(line), fp)) {
87 108 : char *p = line;
88 :
89 108 : if (first_line) {
90 48 : first_line = 0;
91 48 : if ((unsigned char)p[0] == 0xEF &&
92 4 : (unsigned char)p[1] == 0xBB &&
93 4 : (unsigned char)p[2] == 0xBF) {
94 4 : p += 3;
95 : }
96 : }
97 :
98 : /* Skip leading whitespace. */
99 120 : while (*p == ' ' || *p == '\t') p++;
100 :
101 : /* Blank line or comment (# or ;) — skip entirely. */
102 108 : if (*p == '\0' || *p == '\n' || *p == '\r' ||
103 108 : *p == '#' || *p == ';') {
104 16 : continue;
105 : }
106 :
107 92 : if (strncmp(p, key, klen) != 0) continue;
108 46 : p += klen;
109 50 : while (*p == ' ' || *p == '\t') p++;
110 46 : if (*p != '=') continue;
111 46 : p++;
112 52 : while (*p == ' ' || *p == '\t') p++;
113 :
114 : /* Copy the remainder then normalise: trim tail, strip quotes. */
115 46 : size_t n = strlen(p);
116 46 : if (n >= cap) n = cap - 1;
117 :
118 46 : if (found) {
119 2 : logger_log(LOG_WARN,
120 : "credentials: duplicate '%s=' in %s — using the "
121 : "last occurrence", key, path);
122 : }
123 :
124 46 : memcpy(out, p, n);
125 46 : out[n] = '\0';
126 46 : rtrim_inplace(out);
127 46 : unquote_inplace(out);
128 :
129 46 : empty_value = (out[0] == '\0');
130 46 : found = 1;
131 : /* Do not early-return: keep scanning so we detect duplicates and
132 : * honour last-wins semantics. */
133 : }
134 :
135 52 : if (!found) return -2;
136 44 : if (empty_value) return -3;
137 42 : return 0;
138 : }
139 :
140 : /** Return 1 if @p s is a 32-char lowercase-hex string, else 0. */
141 29 : static int is_valid_api_hash(const char *s) {
142 29 : if (!s) return 0;
143 29 : size_t n = strlen(s);
144 29 : if (n != API_HASH_EXPECT) return 0;
145 825 : for (size_t i = 0; i < n; i++) {
146 800 : unsigned char c = (unsigned char)s[i];
147 800 : if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) return 0;
148 : }
149 25 : return 1;
150 : }
151 :
152 35 : int credentials_load(ApiConfig *out) {
153 35 : if (!out) return -1;
154 35 : api_config_init(out);
155 :
156 : /* -- api_id -- */
157 35 : int api_id = 0;
158 35 : const char *env_id = getenv("TG_CLI_API_ID");
159 35 : if (env_id && *env_id) {
160 7 : api_id = atoi(env_id);
161 : }
162 :
163 : /* -- api_hash from env -- */
164 35 : const char *env_hash = getenv("TG_CLI_API_HASH");
165 35 : if (env_hash && *env_hash) {
166 7 : size_t n = strlen(env_hash);
167 7 : if (n > API_HASH_MAX) n = API_HASH_MAX;
168 7 : memcpy(g_api_hash_buf, env_hash, n);
169 7 : g_api_hash_buf[n] = '\0';
170 : } else {
171 28 : g_api_hash_buf[0] = '\0';
172 : }
173 :
174 : /* -- Fall back to ~/.config/tg-cli/config.ini for missing values -- */
175 35 : int hash_len_rejected = 0;
176 63 : if (api_id == 0 || g_api_hash_buf[0] == '\0') {
177 28 : const char *cfg_dir = platform_config_dir();
178 28 : if (cfg_dir) {
179 : char path[1024];
180 28 : snprintf(path, sizeof(path), "%s/tg-cli/config.ini", cfg_dir);
181 :
182 28 : if (api_id == 0) {
183 : char buf[32];
184 28 : int rc = read_ini_key(path, "api_id", buf, sizeof(buf));
185 28 : if (rc == 0) {
186 20 : api_id = atoi(buf);
187 8 : } else if (rc == -3) {
188 2 : logger_log(LOG_WARN,
189 : "credentials: api_id is set to an empty "
190 : "value in %s — ignoring", path);
191 : }
192 : }
193 28 : if (g_api_hash_buf[0] == '\0') {
194 28 : int rc = read_ini_key(path, "api_hash",
195 : g_api_hash_buf,
196 : sizeof(g_api_hash_buf));
197 28 : if (rc == -3) {
198 0 : logger_log(LOG_WARN,
199 : "credentials: api_hash is set to an empty "
200 : "value in %s — ignoring", path);
201 0 : g_api_hash_buf[0] = '\0';
202 28 : } else if (rc == 0 && !is_valid_api_hash(g_api_hash_buf)) {
203 4 : logger_log(LOG_ERROR,
204 : "credentials: api_hash in %s is not a 32-"
205 : "character lowercase hex string — rejecting",
206 : path);
207 4 : g_api_hash_buf[0] = '\0';
208 4 : hash_len_rejected = 1;
209 : }
210 : }
211 : }
212 7 : } else if (!is_valid_api_hash(g_api_hash_buf)) {
213 : /* Env-var supplied a bad-length hash. */
214 0 : logger_log(LOG_ERROR,
215 : "credentials: TG_CLI_API_HASH is not a 32-character "
216 : "lowercase hex string — rejecting");
217 0 : g_api_hash_buf[0] = '\0';
218 0 : hash_len_rejected = 1;
219 : }
220 :
221 35 : int id_missing = (api_id == 0);
222 35 : int hash_missing = (g_api_hash_buf[0] == '\0');
223 :
224 35 : if (id_missing && hash_missing) {
225 4 : logger_log(LOG_ERROR,
226 : "credentials: api_id/api_hash not found. Set "
227 : "TG_CLI_API_ID and TG_CLI_API_HASH env vars, or add "
228 : "api_id=/api_hash= lines to ~/.config/tg-cli/config.ini "
229 : "(run `tg-cli config --wizard` to generate it).");
230 4 : return -1;
231 : }
232 31 : if (id_missing) {
233 4 : logger_log(LOG_ERROR,
234 : "credentials: api_id not found. Set TG_CLI_API_ID or "
235 : "add `api_id=...` to ~/.config/tg-cli/config.ini "
236 : "(run `tg-cli config --wizard`).");
237 4 : return -1;
238 : }
239 27 : if (hash_missing) {
240 6 : if (hash_len_rejected) {
241 4 : logger_log(LOG_ERROR,
242 : "credentials: api_hash rejected (wrong length or "
243 : "non-hex). Obtain a 32-char lowercase hex hash "
244 : "from https://my.telegram.org and re-run "
245 : "`tg-cli config --wizard`.");
246 : } else {
247 2 : logger_log(LOG_ERROR,
248 : "credentials: api_hash not found. Set "
249 : "TG_CLI_API_HASH or add `api_hash=...` to "
250 : "~/.config/tg-cli/config.ini "
251 : "(run `tg-cli config --wizard`).");
252 : }
253 6 : return -1;
254 : }
255 :
256 21 : out->api_id = api_id;
257 21 : out->api_hash = g_api_hash_buf;
258 21 : return 0;
259 : }
|