PushService.vala 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Invercargill.Expressions;
  4. using InvercargillSql;
  5. using InvercargillSql.Orm;
  6. using Inversion;
  7. using Soup;
  8. using Astralis;
  9. using ClankerSaddle.Data;
  10. namespace ClankerSaddle.Services {
  11. // ---- OpenSSL shim (src/Push/web-push-crypto.c): raw P-256 and
  12. // AES-GCM primitives. Pointer parameters deliberately — no array
  13. // lengths cross the boundary. ----
  14. [CCode (cname = "ws_p256_generate")]
  15. internal static extern int crypto_p256_generate(uint8 *priv, uint8 *pub);
  16. [CCode (cname = "ws_p256_public_from_private")]
  17. internal static extern int crypto_p256_public_from_private(uint8 *priv, uint8 *pub);
  18. [CCode (cname = "ws_p256_ecdh")]
  19. internal static extern int crypto_p256_ecdh(uint8 *priv, uint8 *peer, uint8 *secret);
  20. [CCode (cname = "ws_p256_sign")]
  21. internal static extern int crypto_p256_sign(uint8 *priv, uint8 *digest, uint8 *sig);
  22. [CCode (cname = "ws_aes128gcm_encrypt")]
  23. internal static extern int crypto_aes128gcm_encrypt(uint8 *key, uint8 *iv,
  24. uint8 *plaintext, size_t pt_len, uint8 *out, out size_t out_len);
  25. [CCode (cname = "ws_random")]
  26. internal static extern int crypto_random(uint8 *buf, size_t len);
  27. /**
  28. * One outgoing notification (plan 11 task E): `title` carries the
  29. * task title plus the event kind, `body` the question or summary,
  30. * `url` the task page the service worker routes a click to. Pure
  31. * data — the payload builder below is deterministic so tests can
  32. * assert on it end-to-end (the test harness owns the client keys,
  33. * so it can decrypt what was sent).
  34. */
  35. public class PushNotification : Object {
  36. public string title { get; set; default = ""; }
  37. public string body { get; set; default = ""; }
  38. public string url { get; set; default = ""; }
  39. }
  40. /**
  41. * The roll-up of one {@link PushService.send_test} probe: counts
  42. * per outcome plus the first failure's detail for the toast.
  43. * `first_failure` names the HTTP status ("HTTP 401") or the
  44. * transport error text — the dashboard's test button toasts the
  45. * REAL end-to-end result, never a blind success.
  46. */
  47. public class PushTestReport : Object {
  48. public int delivered { get; set; }
  49. public int failed { get; set; }
  50. /** Endpoints that answered 404/410 — pruned during the probe. */
  51. public int pruned { get; set; }
  52. public string first_failure { get; set; default = ""; }
  53. }
  54. /**
  55. * Web Push delivery (plan 11 task E): real Web Push protocol over
  56. * VAPID. The keypair is generated once and persisted in the config
  57. * dir (a `vapid` section inside web-config.json); payloads are
  58. * encrypted per RFC 8291 (ECDH P-256 + HKDF + AES-128-GCM,
  59. * aes128gcm content coding) and authorised by an ES256 VAPID JWT.
  60. *
  61. * Targeting: BLOCK events
  62. * condition go to every push-subscribed user globally; a task's
  63. * first start, failure and completion go only to users holding a
  64. * `task_notify_prefs` row for it ("Notify me").
  65. *
  66. * Delivery is best-effort from a singleton queue: enqueues are
  67. * in-memory (they never block the request), a flush is kicked
  68. * immediately and re-run by the dispatcher's tick while anything
  69. * remains (a failed send retries next tick, up to
  70. * {@link MAX_ATTEMPTS}); endpoints answering 410 Gone (or 404) are
  71. * pruned — the browser already destroyed the subscription.
  72. */
  73. public class PushService : Object {
  74. private const int MAX_ATTEMPTS = 5;
  75. /** The persisted keypair, base64url (empty before {@link ensure_vapid_keys}). */
  76. public static string vapid_public_key { get; private set; default = ""; }
  77. private static string vapid_private_key = "";
  78. private Container container = inject<Container>();
  79. private WebConfig web_config = inject<WebConfig>();
  80. private Soup.Session http = new Soup.Session() {
  81. timeout = 10,
  82. idle_timeout = 10
  83. };
  84. /** {@link ensure_vapid_keys} with the injected config (main's startup call). */
  85. public void ensure_keys_from_config() {
  86. ensure_vapid_keys(web_config);
  87. }
  88. /** A resolved send: one endpoint with its keys and plaintext payload. */
  89. private class SendJob : Object {
  90. public string endpoint = "";
  91. public string p256dh = "";
  92. public string auth = "";
  93. public string payload = "";
  94. public int attempts = 0;
  95. /** The last HTTP status the endpoint answered (0 = transport error). */
  96. public int last_status = 0;
  97. }
  98. /** A queued notification before target resolution. */
  99. private class PendingTarget : Object {
  100. public bool global = false;
  101. public int64 task_id = 0;
  102. public PushNotification notification;
  103. }
  104. private GLib.Queue<PendingTarget> pending = new GLib.Queue<PendingTarget>();
  105. private GLib.Queue<SendJob> queued = new GLib.Queue<SendJob>();
  106. private Mutex queue_lock = Mutex();
  107. private bool flushing = false;
  108. // ------------------------------------------------------------------
  109. // VAPID keys
  110. // ------------------------------------------------------------------
  111. /**
  112. * Loads the VAPID keypair from the config's `vapid` section,
  113. * generating and persisting a fresh one when missing (or when
  114. * the stored pair does not match — the public point is derived
  115. * from the private scalar and compared). Called once from
  116. * `main` before the server accepts requests, so every
  117. * subscriber always sees the same public key.
  118. */
  119. public static void ensure_vapid_keys(WebConfig config) {
  120. string stored_public = "";
  121. string stored_private = "";
  122. var section = config != null ? config.get_section("vapid") : null;
  123. if (section != null) {
  124. stored_public = ((!)section).get_string("public_key", "");
  125. stored_private = ((!)section).get_string("private_key", "");
  126. }
  127. if (keypair_valid(stored_public, stored_private)) {
  128. vapid_public_key = stored_public;
  129. vapid_private_key = stored_private;
  130. return;
  131. }
  132. var priv = new uint8[32];
  133. var pub = new uint8[65];
  134. if (crypto_p256_generate((uint8*) priv, (uint8*) pub) != 1) {
  135. warning("[ClankerSaddle] VAPID keypair generation failed;"
  136. + " web push disabled for this run");
  137. return;
  138. }
  139. vapid_private_key = base64url_encode(priv);
  140. vapid_public_key = base64url_encode(pub);
  141. persist_vapid_keys(config);
  142. print("[ClankerSaddle] Generated VAPID keypair (public key: %s)\n",
  143. vapid_public_key);
  144. }
  145. /** True when the stored pair decodes and the public point matches. */
  146. private static bool keypair_valid(string public_b64, string private_b64) {
  147. uint8[] priv = base64url_decode(private_b64);
  148. uint8[] pub = base64url_decode(public_b64);
  149. if (priv.length != 32 || pub.length != 65 || pub[0] != 0x04) {
  150. return false;
  151. }
  152. var derived = new uint8[65];
  153. if (crypto_p256_public_from_private((uint8*) priv, (uint8*) derived) != 1) {
  154. return false;
  155. }
  156. for (var index = 0; index < 65; index++) {
  157. if (derived[index] != pub[index]) {
  158. return false;
  159. }
  160. }
  161. return true;
  162. }
  163. /**
  164. * Writes the `vapid` section into the config file the app
  165. * loaded (first loaded file; when nothing was loaded, the first
  166. * discovered path — created). Existing keys and sections are
  167. * preserved: the file is re-parsed and only `vapid` replaced.
  168. */
  169. private static void persist_vapid_keys(WebConfig config) {
  170. string path = "";
  171. string[] loaded = config != null ? config.get_loaded_files() : new string[0];
  172. if (loaded.length > 0) {
  173. path = loaded[0];
  174. } else {
  175. try {
  176. string[] discovered = WebConfigLoader.discover_config_paths();
  177. if (discovered.length > 0) {
  178. path = discovered[0];
  179. }
  180. } catch (Error e) {
  181. path = "";
  182. }
  183. }
  184. if (path.length == 0) {
  185. warning("[ClankerSaddle] No config file to persist the VAPID"
  186. + " keypair into; keys are in-memory for this run only");
  187. return;
  188. }
  189. try {
  190. string text = "{}";
  191. if (FileUtils.test(path, FileTest.EXISTS)) {
  192. string contents;
  193. FileUtils.get_contents(path, out contents);
  194. text = contents.strip().length > 0 ? contents : "{}";
  195. }
  196. var parser = new Json.Parser();
  197. parser.load_from_data(text);
  198. var root = parser.get_root();
  199. var document = root != null && root.get_node_type() == Json.NodeType.OBJECT
  200. ? root.get_object() : new Json.Object();
  201. var vapid = new Json.Object();
  202. vapid.set_string_member("public_key", vapid_public_key);
  203. vapid.set_string_member("private_key", vapid_private_key);
  204. document.set_object_member("vapid", vapid);
  205. var generator = new Json.Generator() {
  206. pretty = true,
  207. indent = 2
  208. };
  209. generator.set_root(new Json.Node(Json.NodeType.OBJECT).init_object(document));
  210. FileUtils.set_contents(path, generator.to_data(null) + "\n");
  211. } catch (Error e) {
  212. warning("[ClankerSaddle] Persisting the VAPID keypair into %s"
  213. + " failed: %s", path, e.message);
  214. }
  215. }
  216. // ------------------------------------------------------------------
  217. // Payload builder
  218. // ------------------------------------------------------------------
  219. /** The human label of a block kind in notification titles. */
  220. public static string block_kind_label(string block_type) {
  221. switch (block_type) {
  222. case BLOCK_RFC:
  223. return "RFC raised";
  224. case BLOCK_REVIEW:
  225. return "review requested";
  226. case BLOCK_PLAN:
  227. return "plan review";
  228. default:
  229. return "question raised";
  230. }
  231. }
  232. /**
  233. * The block-event payload (global audience): title = task title
  234. * + kind, body = the question, click-through = the task page.
  235. */
  236. public static PushNotification block_notification(string task_title,
  237. int64 task_id, string block_type, string question) {
  238. return new PushNotification() {
  239. title = @"$(task_title) — $(block_kind_label(block_type))",
  240. body = question,
  241. url = @"/tasks/$(task_id)"
  242. };
  243. }
  244. /**
  245. * The opted-in lifecycle payload: `started` (first start),
  246. * `completed` or `failed`, with the task's summary as the body.
  247. */
  248. public static PushNotification lifecycle_notification(string task_title,
  249. int64 task_id, string kind, string? summary) {
  250. return new PushNotification() {
  251. title = @"$(task_title) — $(kind)",
  252. body = summary != null && ((!)summary).length > 0
  253. ? (!)summary : @"Task $(task_id) $(kind)",
  254. url = @"/tasks/$(task_id)"
  255. };
  256. }
  257. /**
  258. * The goal block-event payload (plan 12, global audience —
  259. * goal blocks always notify): title = goal title + kind, body
  260. * = the question, click-through = the goal page.
  261. */
  262. public static PushNotification goal_block_notification(string goal_title,
  263. int64 goal_id, string block_type, string question) {
  264. string label = block_kind_label(block_type);
  265. if (block_type == BLOCK_COMPLETION) {
  266. label = "completion review";
  267. }
  268. return new PushNotification() {
  269. title = @"$(goal_title) — $(label)",
  270. body = question,
  271. url = @"/goals/$(goal_id)"
  272. };
  273. }
  274. /**
  275. * The goal terminal payload (plan 12, global audience — goal
  276. * success/failure always notify): `succeeded` picks the
  277. * wording; the report body is passed by the caller.
  278. */
  279. public static PushNotification goal_terminal_notification(string goal_title,
  280. int64 goal_id, bool succeeded) {
  281. return new PushNotification() {
  282. title = @"$(goal_title) — $(succeeded ? "complete" : "failed")",
  283. body = succeeded
  284. ? @"Goal $(goal_id) reached its completion review approval"
  285. : @"Goal $(goal_id) was failed by its agent",
  286. url = @"/goals/$(goal_id)"
  287. };
  288. }
  289. /**
  290. * The "Send test notification" payload (per-browser bell): a
  291. * REAL Web Push through the full RFC 8291 + VAPID path whose
  292. * arrival the human can see — the end-to-end probe that was
  293. * missing entirely.
  294. */
  295. public static PushNotification test_notification() {
  296. return new PushNotification() {
  297. title = "Test notification",
  298. body = "A live Web Push from Clanker Saddle — if you can"
  299. + " read this, delivery works.",
  300. url = "/"
  301. };
  302. }
  303. // ------------------------------------------------------------------
  304. // Enqueue + delivery
  305. // ------------------------------------------------------------------
  306. /** Queues a notification for every push-subscribed user (block events). */
  307. public void notify_global(PushNotification notification) {
  308. enqueue_target(new PendingTarget() {
  309. global = true,
  310. notification = notification
  311. });
  312. }
  313. /**
  314. * Queues a notification for the users holding a "Notify me"
  315. * pref for `task_id` (first start / failure / completion).
  316. */
  317. public void notify_opted_in(int64 task_id, PushNotification notification) {
  318. enqueue_target(new PendingTarget() {
  319. global = false,
  320. task_id = task_id,
  321. notification = notification
  322. });
  323. }
  324. private void enqueue_target(PendingTarget target) {
  325. if (vapid_private_key.length == 0) {
  326. return;
  327. }
  328. queue_lock.lock();
  329. pending.push_tail(target);
  330. queue_lock.unlock();
  331. flush.begin();
  332. }
  333. /**
  334. * Delivers everything queued: resolves pending targets into
  335. * per-endpoint jobs (a fresh database scope — subscriptions may
  336. * have changed since the event), then sends each job. Failures
  337. * stay queued for the next run (the dispatcher's tick calls
  338. * this); 410/404 endpoints are pruned instead of retried.
  339. * Single-flight: a concurrent call returns immediately while a
  340. * flush is in progress.
  341. */
  342. public async void flush() {
  343. if (flushing) {
  344. return;
  345. }
  346. flushing = true;
  347. try {
  348. yield resolve_pending();
  349. while (true) {
  350. queue_lock.lock();
  351. SendJob? job = queued.pop_head();
  352. queue_lock.unlock();
  353. if (job == null) {
  354. break;
  355. }
  356. bool requeued = yield deliver((!)job);
  357. if (requeued) {
  358. // A failed send goes back for a LATER pass:
  359. // draining in place would burn every attempt in
  360. // one tight loop. The dispatcher tick (or the
  361. // next enqueue's flush) picks the job back up —
  362. // that spacing IS the retry backoff.
  363. break;
  364. }
  365. }
  366. } finally {
  367. flushing = false;
  368. }
  369. }
  370. /** Moves pending targets into per-endpoint send jobs. */
  371. private async void resolve_pending() {
  372. queue_lock.lock();
  373. var targets = new GLib.List<PendingTarget>();
  374. while (!pending.is_empty()) {
  375. targets.append(pending.pop_head());
  376. }
  377. queue_lock.unlock();
  378. if (targets.length() == 0) {
  379. return;
  380. }
  381. try {
  382. var scope = container.create_scope();
  383. var db = scope.resolve<OrmSession>();
  384. var subscriptions = yield db.query<PushSubscription>().materialise_async();
  385. var jobs = new GLib.Queue<SendJob>();
  386. foreach (var target in targets) {
  387. foreach (var row in subscriptions) {
  388. if (!target.global
  389. && !(yield pref_held(db, row.user_id, target.task_id))) {
  390. continue;
  391. }
  392. jobs.push_tail(new SendJob() {
  393. endpoint = row.endpoint,
  394. p256dh = row.p256dh,
  395. auth = row.auth,
  396. payload = payload_json(target.notification)
  397. });
  398. }
  399. }
  400. if (jobs.is_empty()) {
  401. return;
  402. }
  403. queue_lock.lock();
  404. while (!jobs.is_empty()) {
  405. queued.push_tail(jobs.pop_head());
  406. }
  407. queue_lock.unlock();
  408. } catch (Error e) {
  409. warning("[ClankerSaddle] Push target resolution failed: %s", e.message);
  410. // Targets stay lost this pass; the event is best-effort.
  411. }
  412. }
  413. /** The wire payload JSON (compact, exactly what the SW parses). */
  414. private static string payload_json(PushNotification notification) {
  415. var builder = new Json.Builder();
  416. builder.begin_object();
  417. builder.set_member_name("title");
  418. builder.add_string_value(notification.title);
  419. builder.set_member_name("body");
  420. builder.add_string_value(notification.body);
  421. builder.set_member_name("url");
  422. builder.add_string_value(notification.url);
  423. builder.end_object();
  424. var generator = new Json.Generator();
  425. generator.set_root(builder.get_root());
  426. return generator.to_data(null);
  427. }
  428. private enum SendOutcome {
  429. DELIVERED,
  430. RETRY,
  431. GONE
  432. }
  433. /**
  434. * Applies one job's outcome: DELIVERED drops it, GONE prunes
  435. * the subscription row, anything else requeues it for a later
  436. * tick. Returns true when the job was requeued — the flush
  437. * drain then STOPS (the retry waits for the next tick instead
  438. * of burning every attempt back-to-back), which is what makes
  439. * MAX_ATTEMPTS a real backoff rather than one hot loop.
  440. */
  441. private async bool deliver(SendJob job) {
  442. SendOutcome outcome = SendOutcome.RETRY;
  443. try {
  444. outcome = yield deliver_once(job);
  445. } catch (Error e) {
  446. outcome = SendOutcome.RETRY;
  447. }
  448. if (outcome == SendOutcome.DELIVERED) {
  449. return false;
  450. }
  451. if (outcome == SendOutcome.GONE) {
  452. yield prune_subscription(job.endpoint);
  453. return false;
  454. }
  455. job.attempts++;
  456. if (job.attempts >= MAX_ATTEMPTS) {
  457. // Giving up is loud on purpose: a silently dropped
  458. // notification looks exactly like a delivered one.
  459. warning("[ClankerSaddle] Push to %s gave up after %d attempts"
  460. + " — notification dropped undelivered",
  461. job.endpoint, job.attempts);
  462. return false;
  463. }
  464. queue_lock.lock();
  465. queued.push_tail(job);
  466. queue_lock.unlock();
  467. return true;
  468. }
  469. /**
  470. * The dashboard's "Send test notification" probe (per-browser
  471. * bell, shown only when THIS browser is subscribed): one REAL
  472. * delivery attempt to each of the user's endpoints through the
  473. * same {@link deliver_once} crypto + VAPID path the queue
  474. * uses, awaited inline so the action can report per-endpoint
  475. * outcomes. The shared queue's flush is single-flight and
  476. * fire-and-forget — its outcomes are unobservable from a
  477. * request — so the probe resolves and sends synchronously
  478. * instead of enqueueing. A refused endpoint is reported rather
  479. * than retried here (the queue's own backoff still governs
  480. * real events); 404/410 endpoints are pruned exactly as the
  481. * queue would, so the probe also heals dead rows on demand.
  482. */
  483. public async PushTestReport send_test(OrmSession db, int64 user_id) throws Error {
  484. var report = new PushTestReport();
  485. var jobs = new GLib.List<SendJob>();
  486. foreach (var row in yield db.query<PushSubscription>()
  487. .where(expr("user_id == $0", elem<int64?>(user_id)))
  488. .materialise_async()) {
  489. jobs.append(new SendJob() {
  490. endpoint = row.endpoint,
  491. p256dh = row.p256dh,
  492. auth = row.auth,
  493. payload = payload_json(test_notification())
  494. });
  495. }
  496. foreach (var job in jobs) {
  497. SendOutcome outcome = SendOutcome.RETRY;
  498. string problem = "";
  499. try {
  500. outcome = yield deliver_once(job);
  501. } catch (Error e) {
  502. problem = e.message;
  503. }
  504. if (outcome == SendOutcome.DELIVERED) {
  505. report.delivered++;
  506. continue;
  507. }
  508. if (outcome == SendOutcome.GONE) {
  509. report.pruned++;
  510. yield prune_subscription(job.endpoint);
  511. continue;
  512. }
  513. report.failed++;
  514. if (report.first_failure.length == 0) {
  515. report.first_failure = job.last_status > 0
  516. ? @"HTTP $(job.last_status)" : problem;
  517. }
  518. }
  519. return report;
  520. }
  521. /**
  522. * One Web Push protocol POST: RFC 8291 aes128gcm body, ES256
  523. * VAPID JWT in the Authorization header. Transport errors and
  524. * 5xx throw (retry); 404/410 report {@link SendOutcome.GONE}
  525. * (prune); any OTHER 4xx is a refusal — logged and retried,
  526. * never silently delivered; remaining 2xx/3xx counts as
  527. * delivered (the push service owns delivery from here).
  528. */
  529. private async SendOutcome deliver_once(SendJob job) throws Error {
  530. uint8[] client_public = base64url_decode(job.p256dh);
  531. uint8[] auth_secret = base64url_decode(job.auth);
  532. if (client_public.length != 65 || auth_secret.length != 16) {
  533. return SendOutcome.GONE;
  534. }
  535. // RFC 8291: ephemeral ECDH -> HKDF -> AES-128-GCM.
  536. var eph_priv = new uint8[32];
  537. var eph_pub = new uint8[65];
  538. if (crypto_p256_generate((uint8*) eph_priv, (uint8*) eph_pub) != 1) {
  539. throw new Error(Quark.from_string("clanker-saddle"), 60,
  540. "ephemeral P-256 generation failed");
  541. }
  542. var secret = new uint8[32];
  543. if (crypto_p256_ecdh((uint8*) eph_priv, (uint8*) client_public, (uint8*) secret) != 1) {
  544. throw new Error(Quark.from_string("clanker-saddle"), 61,
  545. "ECDH with the subscription key failed");
  546. }
  547. var ikm = new uint8[48];
  548. for (var index = 0; index < 32; index++) {
  549. ikm[index] = secret[index];
  550. }
  551. for (var index = 0; index < 16; index++) {
  552. ikm[32 + index] = auth_secret[index];
  553. }
  554. var salt = new uint8[16];
  555. if (crypto_random((uint8*) salt, 16) != 1) {
  556. throw new Error(Quark.from_string("clanker-saddle"), 62,
  557. "salt generation failed");
  558. }
  559. // "WebPush: info" || 0x00 || eph_pub || client_pub
  560. var context = new uint8[14 + 65 + 65];
  561. var label = "WebPush: info".data;
  562. for (var index = 0; index < 13; index++) {
  563. context[index] = label[index];
  564. }
  565. context[13] = 0x00;
  566. for (var index = 0; index < 65; index++) {
  567. context[14 + index] = eph_pub[index];
  568. context[14 + 65 + index] = client_public[index];
  569. }
  570. uint8[] cek = hkdf(salt, ikm, context, 16);
  571. uint8[] nonce = hkdf(salt, ikm, context, 12);
  572. uint8[] plaintext = job.payload.data;
  573. var framed = new uint8[plaintext.length + 1];
  574. for (var index = 0; index < plaintext.length; index++) {
  575. framed[index] = plaintext[index];
  576. }
  577. framed[plaintext.length] = 0x02; // last-record delimiter
  578. var ciphertext = new uint8[framed.length + 16];
  579. size_t written = 0;
  580. if (crypto_aes128gcm_encrypt((uint8*) cek, (uint8*) nonce, (uint8*) framed,
  581. framed.length, (uint8*) ciphertext, out written) != 1) {
  582. throw new Error(Quark.from_string("clanker-saddle"), 63,
  583. "payload encryption failed");
  584. }
  585. var body = new uint8[16 + 4 + 1 + 65 + written];
  586. for (var index = 0; index < 16; index++) {
  587. body[index] = salt[index];
  588. }
  589. int64 record_size = plaintext.length + 17;
  590. body[16] = (uint8) ((record_size >> 24) & 0xff);
  591. body[17] = (uint8) ((record_size >> 16) & 0xff);
  592. body[18] = (uint8) ((record_size >> 8) & 0xff);
  593. body[19] = (uint8) (record_size & 0xff);
  594. body[20] = 65;
  595. for (var index = 0; index < 65; index++) {
  596. body[21 + index] = eph_pub[index];
  597. }
  598. for (var index = 0; index < written; index++) {
  599. body[21 + 65 + index] = ciphertext[index];
  600. }
  601. var message = new Soup.Message("POST", job.endpoint);
  602. message.set_request_body_from_bytes("application/octet-stream",
  603. new Bytes(body));
  604. message.request_headers.append("TTL", "60");
  605. message.request_headers.append("Urgency", "high");
  606. message.request_headers.append("Authorization",
  607. @"vapid t=$(vapid_jwt(job.endpoint)), k=$(vapid_public_key)");
  608. try {
  609. yield http.send_and_read_async(message, Priority.DEFAULT, null);
  610. } catch (Error e) {
  611. throw new Error(Quark.from_string("clanker-saddle"), 64,
  612. @"push POST failed: $(e.message)");
  613. }
  614. if (message.status_code == 404 || message.status_code == 410) {
  615. return SendOutcome.GONE;
  616. }
  617. if (message.status_code >= 500) {
  618. job.last_status = (int) message.status_code;
  619. throw new Error(Quark.from_string("clanker-saddle"), 65,
  620. @"push service answered HTTP $(message.status_code)");
  621. }
  622. if (message.status_code >= 400) {
  623. // 401/403 (VAPID rejected) and every other 4xx that is
  624. // not the terminal 404/410 handled above: the push
  625. // service did NOT accept the message. Truthful
  626. // delivery — log it with the endpoint and status,
  627. // retry on later ticks, give up loudly after
  628. // MAX_ATTEMPTS, and never count it as delivered. The
  629. // subscription row stays: the endpoint may recover
  630. // (e.g. a VAPID key rotation heals on a later send).
  631. job.last_status = (int) message.status_code;
  632. warning("[ClankerSaddle] Push endpoint %s refused delivery:"
  633. + " HTTP %d — not delivered, retrying",
  634. job.endpoint, (int) message.status_code);
  635. return SendOutcome.RETRY;
  636. }
  637. return SendOutcome.DELIVERED;
  638. }
  639. /**
  640. * The ES256 VAPID JWT for an endpoint: aud = its origin, exp =
  641. * now + 12h, sub = a mailto contact (`SADDLE_VAPID_SUBJECT`
  642. * overrides), signature = raw r||s.
  643. */
  644. private static string vapid_jwt(string endpoint) {
  645. string origin = endpoint;
  646. int scheme = endpoint.index_of("://");
  647. if (scheme > 0) {
  648. int path = endpoint.index_of("/", scheme + 3);
  649. origin = path > 0 ? endpoint.substring(0, path) : endpoint;
  650. }
  651. string subject = Environment.get_variable("SADDLE_VAPID_SUBJECT")
  652. ?? "mailto:clanker-saddle@localhost";
  653. string header = "{\"typ\":\"JWT\",\"alg\":\"ES256\"}";
  654. string claims = "{\"aud\":\"" + origin + "\",\"exp\":"
  655. + (new DateTime.now_local().to_unix() + 43200).to_string()
  656. + ",\"sub\":\"" + subject + "\"}";
  657. string header_b64 = base64url_encode(header.data);
  658. string claims_b64 = base64url_encode(claims.data);
  659. string signing_input = header_b64 + "." + claims_b64;
  660. var checksum = new Checksum(ChecksumType.SHA256);
  661. size_t digest_len = 32;
  662. var digest = new uint8[32];
  663. checksum.update(signing_input.data, signing_input.length);
  664. checksum.get_digest(digest, ref digest_len);
  665. uint8[] priv = base64url_decode(vapid_private_key);
  666. var signature = new uint8[64];
  667. if (crypto_p256_sign((uint8*) priv, (uint8*) digest, (uint8*) signature) != 1) {
  668. warning("[ClankerSaddle] VAPID JWT signing failed");
  669. return "";
  670. }
  671. return signing_input + "." + base64url_encode(signature);
  672. }
  673. /** Deletes subscriptions whose endpoint went 404/410 Gone. */
  674. private async void prune_subscription(string endpoint) {
  675. try {
  676. var scope = container.create_scope();
  677. var db = scope.resolve<OrmSession>();
  678. foreach (var row in yield db.query<PushSubscription>()
  679. .where(expr("endpoint == $0", elem(endpoint)))
  680. .materialise_async()) {
  681. yield db.delete_async<PushSubscription>(row);
  682. }
  683. print("[ClankerSaddle] Pruned gone push subscription %s\n",
  684. endpoint.substring(0, int.min(endpoint.length, 72)));
  685. } catch (Error e) {
  686. warning("[ClankerSaddle] Pruning push subscription failed: %s", e.message);
  687. }
  688. }
  689. // ------------------------------------------------------------------
  690. // Subscriptions + notify prefs (caller's database scope)
  691. // ------------------------------------------------------------------
  692. /**
  693. * Validates a browser subscription registration: the endpoint
  694. * must be an absolute http(s) URL, `p256dh` a base64url 65-byte
  695. * uncompressed P-256 point and `auth` a base64url 16-byte
  696. * secret. Returns null when valid, else the shown error.
  697. */
  698. public static string? validate_subscription(string endpoint,
  699. string p256dh, string auth) {
  700. if (!(endpoint.has_prefix("http://") || endpoint.has_prefix("https://"))
  701. || endpoint.length <= 8) {
  702. return "The endpoint must be an absolute http(s) URL";
  703. }
  704. uint8[] public_point = base64url_decode(p256dh);
  705. if (public_point.length != 65 || public_point[0] != 0x04) {
  706. return "p256dh must be a base64url 65-byte uncompressed P-256 point";
  707. }
  708. uint8[] auth_secret = base64url_decode(auth);
  709. if (auth_secret.length != 16) {
  710. return "auth must be a base64url 16-byte secret";
  711. }
  712. return null;
  713. }
  714. /**
  715. * Stores (or replaces — the endpoint is unique per browser) a
  716. * user's subscription.
  717. */
  718. public async void upsert_subscription(OrmSession db, int64 user_id,
  719. string endpoint, string p256dh, string auth) throws Error {
  720. var existing = yield db.query<PushSubscription>()
  721. .where(expr("endpoint == $0", elem(endpoint)))
  722. .first_async();
  723. if (existing != null) {
  724. ((!)existing).user_id = user_id;
  725. ((!)existing).p256dh = p256dh;
  726. ((!)existing).auth = auth;
  727. yield db.update_async<PushSubscription>((!)existing);
  728. return;
  729. }
  730. yield db.insert_async<PushSubscription>(new PushSubscription() {
  731. user_id = user_id,
  732. endpoint = endpoint,
  733. p256dh = p256dh,
  734. auth = auth,
  735. created_at = new DateTime.now_local()
  736. });
  737. }
  738. /**
  739. * Removes one of the user's subscription rows by endpoint (a
  740. * no-op when the endpoint belongs to someone else or is gone).
  741. */
  742. public async void remove_subscription(OrmSession db, int64 user_id,
  743. string endpoint) throws Error {
  744. foreach (var row in yield db.query<PushSubscription>()
  745. .where(expr("endpoint == $0 && user_id == $1",
  746. elem(endpoint), elem<int64?>(user_id)))
  747. .materialise_async()) {
  748. yield db.delete_async<PushSubscription>(row);
  749. }
  750. }
  751. /**
  752. * True when the user holds any subscription. Convenience only
  753. * since the per-browser bell: the authoritative per-browser
  754. * state is {@link user_endpoint_fingerprints} matched
  755. * client-side against the browser's own subscription.
  756. */
  757. public async bool user_subscribed(OrmSession db, int64 user_id) throws Error {
  758. foreach (var row in yield db.query<PushSubscription>()
  759. .where(expr("user_id == $0", elem<int64?>(user_id)))
  760. .materialise_async()) {
  761. return true;
  762. }
  763. return false;
  764. }
  765. /**
  766. * This user's subscription endpoint fingerprints, newest
  767. * first (the per-browser bell state): each entry is
  768. * {@link endpoint_fingerprint} of one stored endpoint — never
  769. * the endpoint itself, which stays server-private. The browser
  770. * fingerprints its own pushManager subscription and looks it
  771. * up in the list: present => THIS browser is subscribed (show
  772. * "Turn off"), absent => "Enable" (+ a count of the others).
  773. */
  774. public async string[] user_endpoint_fingerprints(OrmSession db, int64 user_id) throws Error {
  775. var fingerprints = new string[0];
  776. foreach (var row in yield db.query<PushSubscription>()
  777. .where(expr("user_id == $0", elem<int64?>(user_id)))
  778. .order_by_desc(expr("created_at"))
  779. .order_by_desc(expr("id"))
  780. .materialise_async()) {
  781. fingerprints += endpoint_fingerprint(row.endpoint);
  782. }
  783. return fingerprints;
  784. }
  785. /**
  786. * A privacy-safe handle for one endpoint: the first 16 hex
  787. * chars of its SHA-256 — enough for a browser to recognise its
  788. * own subscription, useless for reconstructing the URL. The
  789. * client mirrors this with crypto.subtle (both need a secure
  790. * context, so any browser that can subscribe can compare).
  791. */
  792. public static string endpoint_fingerprint(string endpoint) {
  793. var checksum = new Checksum(ChecksumType.SHA256);
  794. size_t digest_len = 32;
  795. var digest = new uint8[32];
  796. checksum.update(endpoint.data, endpoint.length);
  797. checksum.get_digest(digest, ref digest_len);
  798. var builder = new StringBuilder();
  799. for (var index = 0; index < 8; index++) {
  800. builder.append_printf("%02x", digest[index]);
  801. }
  802. return builder.str;
  803. }
  804. /** True when the user holds a "Notify me" pref for the task. */
  805. public async bool has_notify_pref(OrmSession db, int64 user_id,
  806. int64 task_id) throws Error {
  807. return yield pref_held(db, user_id, task_id);
  808. }
  809. /** Inserts or removes the per-task "Notify me" pref. */
  810. public async void set_notify_pref(OrmSession db, int64 user_id,
  811. int64 task_id, bool enabled) throws Error {
  812. foreach (var row in yield db.query<TaskNotifyPref>()
  813. .where(expr("task_id == $0 && user_id == $1",
  814. elem<int64?>(task_id), elem<int64?>(user_id)))
  815. .materialise_async()) {
  816. if (!enabled) {
  817. yield db.delete_async<TaskNotifyPref>(row);
  818. }
  819. return;
  820. }
  821. if (enabled) {
  822. yield db.insert_async<TaskNotifyPref>(new TaskNotifyPref() {
  823. task_id = task_id,
  824. user_id = user_id
  825. });
  826. }
  827. }
  828. private static async bool pref_held(OrmSession db, int64 user_id,
  829. int64 task_id) throws Error {
  830. var row = yield db.query<TaskNotifyPref>()
  831. .where(expr("task_id == $0 && user_id == $1",
  832. elem<int64?>(task_id), elem<int64?>(user_id)))
  833. .first_async();
  834. return row != null;
  835. }
  836. // ------------------------------------------------------------------
  837. // Primitives
  838. // ------------------------------------------------------------------
  839. /** HKDF (RFC 5869, SHA-256): extract(salt, ikm) then expand(info). */
  840. private static uint8[] hkdf(uint8[] salt, uint8[] ikm, uint8[] info,
  841. size_t length) {
  842. var extract_hmac = new Hmac(ChecksumType.SHA256, salt);
  843. extract_hmac.update(ikm);
  844. size_t prk_len = 32;
  845. var prk = new uint8[32];
  846. extract_hmac.get_digest(prk, ref prk_len);
  847. var output = new uint8[length];
  848. var counter = new uint8[1];
  849. uint8[] accumulator = new uint8[0];
  850. size_t produced = 0;
  851. uint8 block_index = 1;
  852. while (produced < length) {
  853. var expand_hmac = new Hmac(ChecksumType.SHA256, prk);
  854. expand_hmac.update(accumulator);
  855. expand_hmac.update(info);
  856. counter[0] = block_index;
  857. expand_hmac.update(counter);
  858. size_t block_len = 32;
  859. var block = new uint8[32];
  860. expand_hmac.get_digest(block, ref block_len);
  861. size_t take = 32;
  862. if (length - produced < 32) {
  863. take = length - produced;
  864. }
  865. for (var index = 0; index < take; index++) {
  866. output[produced + index] = block[index];
  867. }
  868. produced += take;
  869. accumulator = block;
  870. block_index++;
  871. }
  872. return output;
  873. }
  874. /** Standard base64 -> base64url (unpadded). */
  875. public static string base64url_encode(uint8[] data) {
  876. string encoded = Base64.encode(data);
  877. var builder = new StringBuilder();
  878. for (var index = 0; index < encoded.length; index++) {
  879. char character = encoded[index];
  880. if (character == '+') {
  881. builder.append_c('-');
  882. } else if (character == '/') {
  883. builder.append_c('_');
  884. } else if (character != '=') {
  885. builder.append_c(character);
  886. }
  887. }
  888. return builder.str;
  889. }
  890. /** base64url (padded or not) -> bytes; junk decodes empty. */
  891. public static uint8[] base64url_decode(string text) {
  892. var normalised = new StringBuilder();
  893. for (var index = 0; index < text.length; index++) {
  894. char character = text[index];
  895. if (character == '-') {
  896. normalised.append_c('+');
  897. } else if (character == '_') {
  898. normalised.append_c('/');
  899. } else {
  900. normalised.append_c(character);
  901. }
  902. }
  903. while (normalised.len % 4 != 0) {
  904. normalised.append_c('=');
  905. }
  906. return Base64.decode(normalised.str);
  907. }
  908. }
  909. }