# Web Push (completion program R9), with Mastodon and the pushsink. push_alice subscribes with keys the sink made for # her: a mention from mastouser reaches the sink encrypted (aes128gcm), signed by the server's VAPID key for the sink's # origin, and decrypts to Mastodon's payload with notification_type mention and her own access token. Turning the mention # alert off stops it while a favourite still comes; policy=followed stops mastouser's mention until she follows him; # standard=false is the aesgcm draft and decrypts too, and a new subscription replaces the old; push_bob, a sibling persona # of the same login, never gets hers, nor she his; DELETE stops the pushes; the sink answering 410 removes the # subscription; a revoked token takes its subscription with it. Needs the mastodon and pushsink peers (batch A); run # after featured. M=https://mastodon.test:6443 mcurl() { curl -sk --resolve mastodon.test:6443:127.0.0.1 "$@"; } PS=https://pushsink.test:6443 scurl() { curl -sk --resolve pushsink.test:6443:127.0.0.1 "$@"; } . "$here/peers/mastodon.sh" code() { curl -s -o /dev/null -w '%{http_code}' "$@"; } # sink_new: a subscription of the sink's own, its keys made there, told the server's key to check the VAPID JWT with sink_new() { scurl -X POST "$PS/subscriptions" -H 'Content-Type: application/json' -d "{\"server_key\":\"$server_key\"}"; } sink_count() { scurl "$PS/subscriptions/$1" | j "print(d['received'])"; } # sink_last sink_last() { scurl "$PS/subscriptions/$1/received" | j "$2"; } sink_waits() { local sid=$1 want=$2; until_true 30 '[ "$(sink_count "$sid")" -ge "$want" ]'; } # subscribe [policy=]: Mastodon's form subscribe() { local auth=$1 sink=$2 standard=$3 args=() a shift 3 for a in "$@"; do case $a in policy=*) args+=(-d "data[policy]=${a#policy=}") ;; *) args+=(-d "data[alerts][$a]=true") ;; esac done curl -s -X POST -H "$auth" "$P/api/v1/push/subscription" \ --data-urlencode "subscription[endpoint]=$(echo "$sink" | j "print(d['endpoint'])")" \ --data-urlencode "subscription[keys][p256dh]=$(echo "$sink" | j "print(d['keys']['p256dh'])")" \ --data-urlencode "subscription[keys][auth]=$(echo "$sink" | j "print(d['keys']['auth'])")" \ -d "subscription[standard]=$standard" "${args[@]}" } # m_mention : mastouser's public post naming a persona here m_mention() { mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/statuses" --data-urlencode "status=@$1@privapub.test $2" -d visibility=public; } # notified : the persona holds a mention with these words (it arrived, pushed or not) notified() { curl -s -H "$1" "$P/api/v1/notifications?types[]=mention&limit=30" | j "print(any('$2' in (n.get('status') or {}).get('content', '') for n in d))"; } # arrives_unpushed : the mention arrived and the sink got nothing arrives_unpushed() { local auth=$1 words=$2 sid=$3 had=$4 until_true 30 '[ "$(notified "$auth" "$words")" = True ]' || return 1 sleep 6 [ "$(sink_count "$sid")" = "$had" ] } echo "push" run=$(date +%s) AT=$(privapub_token push_alice "read write follow push"); AH="Authorization: Bearer $AT" BT=$(privapub_token push_bob "read write follow push"); BH="Authorization: Bearer $BT" MT=$(mastodon_token); MH="Authorization: Bearer $MT" [ -n "$AT" ] && [ -n "$BT" ] && [ -n "$MT" ] && ok "tokens for push_alice, push_bob (her sibling, the same login) and mastouser" \ || { ko "a token is missing"; return 1; } echo " the server's key" server_key=$(curl -s "$P/api/v2/instance" | j "print(d['configuration']['vapid']['public_key'])") [ "$(python3 -c "import base64,sys; k=base64.urlsafe_b64decode(sys.argv[1]); print(len(k) == 65 and k[0] == 4)" "$server_key" 2>/dev/null)" = True ] \ && ok "the instance API gives the VAPID key (an uncompressed P-256 point)" || ko "configuration.vapid: $server_key" [ "$(curl -s -X POST "$P/api/v1/apps" -d client_name=push-check -d redirect_uris=urn:ietf:wg:oauth:2.0:oob -d scopes=push | j "print(d['vapid_key'])")" = "$server_key" ] \ && ok "an app's vapid_key is the same key" || ko "the app's vapid_key differs" [ "$(curl -s "$P/api/v1/instance" | j "print(d['version'].startswith('4.5.0 (compatible; PrivaPub'))")" = True ] \ && [ "$(curl -s "$P/api/v2/instance" | j "print(d['version'].startswith('4.5.0 (compatible; PrivaPub') and d['api_versions']['mastodon'] == 7)")" = True ] \ && ok "both instance APIs say 4.5.0 (compatible; PrivaPub ...)" || ko "the version: $(curl -s "$P/api/v2/instance" | j "print(d['version'])")" echo " a mention, pushed" m_on_p=$(curl -s -H "$AH" "$P/api/v2/search?q=mastouser@mastodon.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") # (a run before may have left the follow the policy check needs gone) curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$m_on_p/unfollow" for persona in push_alice push_bob; do mcurl -o /dev/null -H "$MH" "$M/api/v2/search?q=@$persona@privapub.test&resolve=true&type=accounts" done sa=$(sink_new); sa_id=$(echo "$sa" | j "print(d['id'])") made=$(subscribe "$AH" "$sa" true mention favourite policy=all) [ "$(echo "$made" | j "print(d['standard'] is True and d['alerts']['mention'] is True and d['alerts']['reblog'] is False and d['policy'] == 'all' and d['server_key'] == '$server_key' and d['endpoint'].startswith('https://pushsink.test/push/'))")" = True ] \ && ok "push_alice subscribes with the sink's keys (standard)" || ko "the subscription: $(echo "$made" | head -c 400)" [ "$(curl -s -H "$AH" "$P/api/v1/push/subscription" | j "print(d['id'])")" = "$(echo "$made" | j "print(d['id'])")" ] \ && ok "GET answers it" || ko "GET: $(curl -s -H "$AH" "$P/api/v1/push/subscription" | head -c 300)" [ "$(code -H "$BH" "$P/api/v1/push/subscription")" = 404 ] && ok "push_bob's token has none" || ko "push_bob's token sees a subscription" m_mention push_alice "first push $run" sink_waits "$sa_id" 1 \ && [ "$(sink_last "$sa_id" "r = d[-1]; p = r['payload'] or {}; print(r['ok'] and r['status'] == 201 and r['encoding'] == 'aes128gcm' and p.get('notification_type') == 'mention' and 'first push $run' in p.get('body', ''))")" = True ] \ && ok "mastouser's mention reaches the sink, aes128gcm, and decrypts to a mention" || ko "the push: $(scurl "$PS/subscriptions/$sa_id/received" | head -c 800)" [ "$(sink_last "$sa_id" "print(d[-1]['payload']['access_token'] == '$AT')")" = True ] && ok "it carries push_alice's own access token" \ || ko "the push's access_token is not hers" [ "$(sink_last "$sa_id" "r = d[-1]; v = r['vapid']; print(v['aud'] == 'https://pushsink.test' and v['sub'].startswith(('mailto:', 'https:')) and 'push_alice' not in v['sub'] and r['headers']['authorization_scheme'] == 'vapid' and int(r['headers']['ttl']) > 0 and r['headers']['urgency'] == 'normal')")" = True ] \ && ok "signed for the sink's origin by the server, TTL and Urgency set, no persona in the sub" || ko "the VAPID claims: $(sink_last "$sa_id" "print(d[-1]['vapid'], d[-1]['headers'])")" pushed_id=$(sink_last "$sa_id" "print(d[-1]['payload']['notification_id'])") [ "$(curl -s -H "$AH" "$P/api/v1/notifications/$pushed_id" | j "print(d['type'] == 'mention' and 'first push $run' in d['status']['content'])")" = True ] \ && ok "its notification_id is her notification" || ko "notification $pushed_id: $(curl -s -H "$AH" "$P/api/v1/notifications/$pushed_id" | head -c 200)" [ "$(sink_last "$sa_id" "p = d[-1]['payload']; print(p['icon'].startswith('https://privapub.test/') and 'mastouser' in p['title'] and p['preferred_locale'] != '')")" = True ] \ && ok "its icon goes through PrivaPub, its title names mastouser" || ko "icon and title: $(sink_last "$sa_id" "print(d[-1]['payload'])")" echo " alerts and policy" changed=$(curl -s -X PUT -H "$AH" "$P/api/v1/push/subscription" -d "data[alerts][favourite]=true") [ "$(echo "$changed" | j "print(d['alerts']['mention'] is False and d['alerts']['favourite'] is True and d['policy'] == 'all')")" = True ] \ && ok "PUT turns the mention alert off" || ko "PUT: $(echo "$changed" | head -c 300)" before=$(sink_count "$sa_id") m_mention push_alice "unwanted $run" arrives_unpushed "$AH" "unwanted $run" "$sa_id" "$before" && ok "with the alert off, the mention arrives unpushed" || ko "a mention was pushed with its alert off" alice_post=$(curl -s -X POST -H "$AH" "$P/api/v1/statuses" --data-urlencode "status=push_alice posts $run" -d visibility=public | j "print(d['uri'])") m_post=$(mcurl -H "$MH" "$M/api/v2/search?q=$alice_post&resolve=true&type=statuses" | j "print(d['statuses'][0]['id'])") mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/statuses/$m_post/favourite" sink_waits "$sa_id" $((before + 1)) && [ "$(sink_last "$sa_id" "print(d[-1]['ok'] and d[-1]['payload']['notification_type'])")" = favourite ] \ && ok "his favourite of her post is pushed" || ko "no favourite push: $(sink_last "$sa_id" "print(d[-1])")" curl -s -o /dev/null -X PUT -H "$AH" "$P/api/v1/push/subscription" -d "data[alerts][mention]=true" -d "data[policy]=followed" before=$(sink_count "$sa_id") m_mention push_alice "a stranger $run" arrives_unpushed "$AH" "a stranger $run" "$sa_id" "$before" && ok "policy=followed: mastouser, whom she does not follow, is not pushed" \ || ko "a non-followed sender was pushed" curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$m_on_p/follow" until_true 30 '[ "$(curl -s -H "$AH" "$P/api/v1/accounts/relationships?id[]=$m_on_p" | j "print(d[0][\"following\"])")" = True ]' \ && ok "push_alice follows mastouser" || ko "her follow was not accepted" m_mention push_alice "a friend now $run" sink_waits "$sa_id" $((before + 1)) && [ "$(sink_last "$sa_id" "print('a friend now $run' in d[-1]['payload']['body'])")" = True ] \ && ok "once followed, his mention is pushed" || ko "a followed sender was not pushed" echo " the aesgcm draft" sl=$(sink_new); sl_id=$(echo "$sl" | j "print(d['id'])") [ "$(subscribe "$AH" "$sl" false mention | j "print(d['standard'])")" = False ] && ok "push_alice subscribes again, standard=false" || ko "the legacy subscription" before=$(sink_count "$sa_id") m_mention push_alice "the draft $run" sink_waits "$sl_id" 1 \ && [ "$(sink_last "$sl_id" "r = d[-1]; print(r['ok'] and r['encoding'] == 'aesgcm' and r['headers']['authorization_scheme'] == 'WebPush' and 'p256ecdsa=' in r['headers']['crypto_key'] and r['headers']['encryption'].startswith('salt=') and r['payload']['notification_type'] == 'mention' and r['payload']['access_token'] == '$AT')")" = True ] \ && ok "aesgcm, with Encryption and Crypto-Key, decrypts to her mention" || ko "the aesgcm push: $(scurl "$PS/subscriptions/$sl_id/received" | head -c 800)" sleep 3 [ "$(sink_count "$sa_id")" = "$before" ] && ok "the subscription it replaced gets nothing" || ko "the replaced subscription is still pushed" echo " siblings" sb=$(sink_new); sb_id=$(echo "$sb" | j "print(d['id'])") subscribe "$BH" "$sb" true mention policy=all >/dev/null alice_before=$(sink_count "$sl_id") m_mention push_alice "for alice only $run" sink_waits "$sl_id" $((alice_before + 1)); sleep 4 [ "$(sink_count "$sb_id")" = 0 ] && ok "push_bob's subscription never gets push_alice's mention" || ko "a sibling's subscription got her push" m_mention push_bob "for bob only $run" sink_waits "$sb_id" 1 && [ "$(sink_last "$sb_id" "p = d[-1]['payload']; print(p['access_token'] == '$BT' and 'for bob only $run' in p['body'])")" = True ] \ && ok "push_bob's own mention, with his own token" || ko "push_bob's push: $(scurl "$PS/subscriptions/$sb_id/received" | head -c 400)" sleep 3 [ "$(sink_count "$sl_id")" = "$((alice_before + 1))" ] && [ "$(sink_last "$sl_id" "print(all(r['payload']['access_token'] == '$AT' for r in d))")" = True ] \ && ok "push_alice gets nothing of his, and only her token" || ko "push_alice's subscription got push_bob's" [ "$(curl -s -X DELETE -H "$BH" "$P/api/v1/push/subscription")" = "{}" ] && ok "push_bob unsubscribes" || ko "push_bob's DELETE" echo " DELETE, 410, revocation" [ "$(curl -s -X DELETE -H "$AH" "$P/api/v1/push/subscription")" = "{}" ] && [ "$(code -H "$AH" "$P/api/v1/push/subscription")" = 404 ] \ && ok "DELETE: the subscription is gone" || ko "DELETE left it" before=$(sink_count "$sl_id") m_mention push_alice "after delete $run" arrives_unpushed "$AH" "after delete $run" "$sl_id" "$before" && ok "nothing is pushed after DELETE" || ko "pushed after DELETE" sg=$(sink_new); sg_id=$(echo "$sg" | j "print(d['id'])") subscribe "$AH" "$sg" true mention >/dev/null scurl -o /dev/null -X POST "$PS/subscriptions/$sg_id/answer" -H 'Content-Type: application/json' -d '{"status": 410}' m_mention push_alice "gone $run" sink_waits "$sg_id" 1 && until_true 15 '[ "$(code -H "$AH" "$P/api/v1/push/subscription")" = 404 ]' \ && ok "the sink answering 410 removes the subscription" || ko "after a 410: $(curl -s -H "$AH" "$P/api/v1/push/subscription" | head -c 200)" before=$(sink_count "$sg_id") m_mention push_alice "still gone $run" arrives_unpushed "$AH" "still gone $run" "$sg_id" "$before" && ok "and nothing more goes there" || ko "pushed after the 410" oauth=$("$here/../smoke/oauth.sh" "$P" "$ROOT_USER" "$ROOT_PASS" push_alice "read write follow push") RT=$(echo "$oauth" | cut -d' ' -f1); rcid=$(echo "$oauth" | cut -d' ' -f2); rcs=$(echo "$oauth" | cut -d' ' -f3) sr=$(sink_new); sr_id=$(echo "$sr" | j "print(d['id'])") subscribe "Authorization: Bearer $RT" "$sr" true mention >/dev/null curl -s -o /dev/null -X POST "$P/oauth/revoke" -d "token=$RT" -d "client_id=$rcid" -d "client_secret=$rcs" [ "$(code -H "Authorization: Bearer $RT" "$P/api/v1/push/subscription")" = 401 ] && ok "the revoked token is refused" || ko "the revoked token still works" m_mention push_alice "revoked $run" arrives_unpushed "$AH" "revoked $run" "$sr_id" 0 && ok "a revoked token's subscription is pushed nothing" || ko "pushed to a revoked token's subscription" [ "$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'db.PushSubscription.countDocuments()' 2>/dev/null | tail -1)" = 0 ] \ && ok "no subscription is left" || ko "subscriptions left: $(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'db.PushSubscription.countDocuments()' 2>/dev/null | tail -1)" curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$m_on_p/unfollow"