Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

About:Pharmacopedia.ext: Difference between revisions

From Pharmacopedia
[checked revision][checked revision]
Drop wikitext H1 so the orientation block becomes the lead section and == sections collapse independently on mobile (per Mark, 2026-05-23)
0.9.8.7 close-out: star hold-to-expand model, editor enhancements module, vote removal (boss-claude)
 
(2 intermediate revisions by the same user not shown)
Line 1: Line 1:
'''Version:''' 0.9.8.6 · '''Requires:''' MediaWiki >= 1.46.0 · PHP >= 8.5
'''Version:''' 0.9.8.7 · '''Requires:''' MediaWiki >= 1.46.0 · PHP >= 8.5
'''Author:''' MDElliottMD · '''License:''' GPL-2.0-or-later
'''Author:''' MDElliottMD · '''License:''' GPL-2.0-or-later
'''Source:''' <code>/var/www/mediawiki/extensions/Pharmacopedia/</code>
'''Source:''' <code>/var/www/mediawiki/extensions/Pharmacopedia/</code>
Line 6: Line 6:


* Structured medicine pages via the <code><nowiki>{{MedTemplate}}</nowiki></code> template
* Structured medicine pages via the <code><nowiki>{{MedTemplate}}</nowiki></code> template
* Per-user rating on effects, problems, titration strategies, anecdotes, and drug-drug interactions (continuous 0–100 sliders, ±100 valence; no 0–5 likert anywhere)
* Per-user rating on effects, problems, titration strategies, anecdotes, and drug-drug interactions (continuous 0–100 sliders, ±100 valence; no 0–5 likert anywhere); ratings use a hold-to-expand star widget (300 ms press, spring animation, drag-commit with pixel-travel + value-delta guards); voters can remove their own committed rating
* Binary AND choice/multi voting on arbitrary content (<code>type="single"</code> / <code>type="multi"</code> with 2-5 options, results-visibility policy per element)
* Binary AND choice/multi voting on arbitrary content (<code>type="single"</code> / <code>type="multi"</code> with 2-5 options, results-visibility policy per element)
* Two-perspective data capture (personal vs. provider) wherever clinically meaningful
* Two-perspective data capture (personal vs. provider) wherever clinically meaningful
Line 39: Line 39:
* '''Backend (PHP):''' <code>includes/</code>, one class per parser tag, store, special page, or API module. Auto-loaded under <code>MediaWiki\Extension\Pharmacopedia\</code>. Assessment classes under <code>includes/Assessments/</code>. API modules under <code>includes/Api/</code>.
* '''Backend (PHP):''' <code>includes/</code>, one class per parser tag, store, special page, or API module. Auto-loaded under <code>MediaWiki\Extension\Pharmacopedia\</code>. Assessment classes under <code>includes/Assessments/</code>. API modules under <code>includes/Api/</code>.
* '''Frontend (JS / CSS):''' multiple ResourceModules per surface area:
* '''Frontend (JS / CSS):''' multiple ResourceModules per surface area:
** <code>ext.pharmacopedia</code>: main IIFE (chip-picker, dx autocomplete, BFI-10 compute, vote logic for both binary and choice/multi)
** <code>ext.pharmacopedia</code>: main IIFE (chip-picker, dx autocomplete, BFI-10 compute, vote logic for both binary and choice/multi, hold-to-expand star rating model with spring animation and drag-commit, vote removal)
** <code>ext.pharmacopedia.styles</code>: base extension stylesheet (self-hosted Geist / Newsreader / Source Serif fonts, core component styling)
** <code>ext.pharmacopedia.styles</code>: base extension stylesheet (self-hosted Geist / Newsreader / Source Serif fonts, core component styling)
** <code>ext.pharmacopedia.blocksave</code>: debounced autosave per block (race-safe)
** <code>ext.pharmacopedia.blocksave</code>: debounced autosave per block (race-safe)
Line 49: Line 49:
** <code>ext.pharmacopedia.perspective</code>: observer-perspective form enhancement (slider readout, progress, consent/delete confirm)
** <code>ext.pharmacopedia.perspective</code>: observer-perspective form enhancement (slider readout, progress, consent/delete confirm)
** <code>ext.pharmacopedia.administer</code>: the administer-to-others surfaces (take-flow slider readout + "Not sure" toggling, owner-hub styling)
** <code>ext.pharmacopedia.administer</code>: the administer-to-others surfaces (take-flow slider readout + "Not sure" toggling, owner-hub styling)
** <code>ext.pharmacopedia.editor</code>: editor enhancements loaded on <code>action=edit/submit</code>; smart paste converts bare PMID or DOI from the clipboard into a formatted <code>&lt;ref&gt;</code> tag (PubMed eutils / CrossRef); house-rules linter flags banned terms and em-dashes on submit with a dismissable warning; quick-ref stub (Ctrl+Alt+R) inserts a journal-article <code>&lt;ref&gt;</code> skeleton
** <code>ext.pharmacopedia.observation</code>: quick-add observation textarea + live preview
** <code>ext.pharmacopedia.observation</code>: quick-add observation textarea + live preview
** <code>ext.pharmacopedia.refupgrade</code>: bulk linker for free-text → structured refs
** <code>ext.pharmacopedia.refupgrade</code>: bulk linker for free-text → structured refs
Line 68: Line 69:
TLS terminates at Apache 2 (mod_ssl) on the same host as the application. No CDN, no reverse proxy, no load balancer is in front. Certificate is a Let's Encrypt ECDSA leaf, renewed by <code>certbot.timer</code> (fires twice daily, renews when within 30 days of expiry). Private key on disk is mode 600 root:root in <code>/etc/letsencrypt/archive/pharmacopedia.wiki/</code>.
TLS terminates at Apache 2 (mod_ssl) on the same host as the application. No CDN, no reverse proxy, no load balancer is in front. Certificate is a Let's Encrypt ECDSA leaf, renewed by <code>certbot.timer</code> (fires twice daily, renews when within 30 days of expiry). Private key on disk is mode 600 root:root in <code>/etc/letsencrypt/archive/pharmacopedia.wiki/</code>.


Apache TLS config (from <code>/etc/letsencrypt/options-ssl-apache.conf</code>, loaded by every Pharmacopedia vhost):
Apache TLS config (live from <code>/etc/letsencrypt/options-ssl-apache.conf</code> + <code>/etc/apache2/mods-enabled/ssl.conf</code>):


  SSLProtocol        all -SSLv2 -SSLv3 -TLSv1 -TLSv1.1
  SSLProtocol        all -SSLv3
  SSLCipherSuite      ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:
  SSLCipherSuite      HIGH:!aNULL
                    ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:
                    ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:
                    DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
SSLHonorCipherOrder off
  SSLSessionTickets  off
  SSLSessionTickets  off


Effective enabled: TLS 1.2 + TLS 1.3 only. TLS 1.0 / 1.1 refused. <code>SSLSessionTickets off</code> preserves forward secrecy across server restarts.
Effective TLS range: TLS 1.0 through 1.3 (SSLv3 explicitly refused). The cipher suite shorthand <code>HIGH:!aNULL</code> covers all ECDHE + DHE forward-secrecy ciphers but does not exclude TLSv1.0 or TLSv1.1 at the protocol layer. Hardening to an explicit modern list and <code>SSLProtocol -TLSv1 -TLSv1.1</code> is queued; see ''Honest limitations'' below. <code>SSLSessionTickets off</code> preserves forward secrecy across server restarts.


HSTS:
HSTS:
Line 84: Line 81:
  Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
  Strict-Transport-Security: max-age=63072000; includeSubDomains; preload


Two years, subdomains included, preload-ready.
Two years, subdomains included, preload-ready. The <code>preload</code> directive is present in the header; submission to the Chromium HSTS preload list is pending.


=== HTTP security headers ===
=== HTTP security headers ===
Line 150: Line 147:
The high-trust paths and their modes (no values published):
The high-trust paths and their modes (no values published):


  /var/www/mediawiki/LocalSettings.php        640 www-data:www-data
  /var/www/mediawiki/LocalSettings.php        640 root:www-data
   contains: $wgSecretKey, $wgUpgradeKey, $wgDBpassword,
   contains: $wgSecretKey, $wgUpgradeKey, $wgDBpassword,
             $wgTurnstileSecretKey, $wgSMTP['password'],
             $wgTurnstileSecretKey, $wgSMTP['password'],
Line 181: Line 178:
MediaWiki passwords are stored as PBKDF2 hashes (MW core default). Verified across every <code>user_password</code> row: HMAC-SHA-512 inner hash, 30,000 iterations, 64-byte derived key, 16-byte random salt per user. The <code>$wgPasswordConfig</code> default is unmodified; bcrypt is available in MW core but not enabled.
MediaWiki passwords are stored as PBKDF2 hashes (MW core default). Verified across every <code>user_password</code> row: HMAC-SHA-512 inner hash, 30,000 iterations, 64-byte derived key, 16-byte random salt per user. The <code>$wgPasswordConfig</code> default is unmodified; bcrypt is available in MW core but not enabled.


Two-factor authentication via the OATHAuth extension. Default module is TOTP per RFC 6238: HMAC-SHA-1 inner, 6 digits, 30-second time-step, 80-bit shared secret. Ten recovery codes per user, each a random 10-character string, hashed at rest, consumed on use. WebAuthn / FIDO2 (passkey) is available in the same extension and is the operator's first-choice path.
Two-factor authentication via the OATHAuth extension. Default module is TOTP per RFC 6238: HMAC-SHA-1 inner, 6 digits, 30-second time-step, 80-bit shared secret. Five recovery codes per user, each a random string, hashed at rest, consumed on use. WebAuthn / FIDO2 (passkey) is available in the same extension and is the operator's first-choice path.


==== AdminCrypto ====
==== AdminCrypto ====
Line 200: Line 197:
==== OAuth 2.0 (iOS app) ====
==== OAuth 2.0 (iOS app) ====


The iOS app authenticates against the wiki via the MWOAuth extension. RSA-4096 signing keypair generated 2026-05-22 with <code>openssl genrsa</code>; JWT signing algorithm RS256. Access tokens live 1 hour; refresh tokens 1 month (MWOAuth defaults). PKCE with S256 challenge is REQUIRED for public clients (<code>$wgOAuth2RequireCodeChallengeForPublicClients = true</code>); the iOS bundle never holds a client secret. Tokens are stored at rest in the wiki's session cache keyed by hashed token (the tokens themselves are opaque to the cache row). The browser-to-app handoff goes via a small HTML+JS bridge at <code>https://pharmacopedia.wiki/app/oauth-callback</code> that forwards the authorization code + state to the <code>pharmacopedia://oauth</code> custom URL scheme.
The iOS app authenticates against the wiki via the MWOAuth extension. RSA-4096 signing keypair generated 2026-05-22 with <code>openssl genrsa</code>; JWT signing algorithm RS256. Access tokens live 1 hour; refresh tokens 1 month (MWOAuth defaults). PKCE with S256 challenge is REQUIRED for public clients (<code>$wgOAuth2RequireCodeChallengeForPublicClients = true</code>); the iOS bundle never holds a client secret. Tokens are stored at rest in the wiki's session cache keyed by hashed token (the tokens themselves are opaque to the cache row). The browser-to-app handoff goes via a small HTML+JS bridge at <code>https://pharmacopedia.wiki/app/oauth-callback</code> that forwards the authorization code + state to the <code>pharmacopedia://oauth</code> custom URL scheme. Special:UserLogin and Special:CreateAccount also carry <code>autocomplete</code> field attributes (<code>username</code>, <code>current-password</code> / <code>new-password</code>, <code>email</code>) injected by the <code>AuthChangeFormFields</code> hook so iOS Safari + system password managers offer the right credentials on the right field.


==== Voter anonymity ====
==== Voter anonymity ====


Every vote stores a HMAC-SHA-256 of the voter's user id, salted with <code>$wgPharmacopediaVoteHashSecret</code> (256 bits, never rotated by policy). The hash is a 64-character hex string in <code>pcp_votes</code> in place of the user id, so an administrator reading the votes table cannot map a vote back to an identity without the secret. The secret lives only in LocalSettings.
Every vote stores a HMAC-SHA-256 of the voter's user id, salted with <code>$wgPharmacopediaVoteHashSecret</code> (256 bits, never rotated by policy). The hash is a 64-character hex string in <code>pcp_votes</code> in place of the user id, so an administrator reading the votes table cannot map a vote back to an identity without the secret. The secret lives only in LocalSettings.
==== Perspective-invite tokens ====
The perspective subsystem's invite tokens are 24-byte URL-safe random strings handed to invitees in <code>Special:Perspective/&lt;token&gt;</code> links. As of 0.9.8.7, every new invite stores BOTH the cleartext token in <code>pcp_perspective_invite.pvi_token</code> AND a SHA-256 hash of the same token in a new <code>pvi_token_hash</code> column (<code>BINARY(32)</code>, <code>UNIQUE</code>). Lookup at <code>PerspectiveStore::resolveToken</code> hashes the inbound URL token and searches by hash first; a cleartext-column fallback covers any row not yet backfilled at deploy edge. Hashing reuses the canonical <code>AdminCrypto::hashInviteToken</code> helper (raw 32-byte SHA-256), the same shape as the existing <code>pcp_administer_invites.inv_token_hash</code> pattern.
Honest current status: this is half-shipped on purpose. The cleartext <code>pvi_token</code> column is still present so the lookup-fallback works during the dual-write cycle; 0.9.8.8 drops the cleartext column and the fallback branch, at which point a database-read attacker can no longer extract usable perspective-invite tokens.


=== Backups ===
=== Backups ===
Line 213: Line 216:
     --passphrase-file /root/.backup-passphrase
     --passphrase-file /root/.backup-passphrase


GPG packet inspection on a current bundle confirms cipher 9 (AES256), S2K mode 3 (iterated and salted), S2K count 65,011,712 iterations, MDC method 2 (modification-detection code present). Local retention is 7 days; off-host (Dropbox via rclone) is 60 days. The passphrase file is 64 bytes random, mode 600 root:root, never transmitted off-host.
GPG packet inspection on a current bundle confirms cipher 9 (AES256), S2K mode 3 (iterated and salted), S2K count 65,011,712 iterations, MDC method 2 (modification-detection code present). Local retention is 7 days; off-host (Dropbox via rclone) is 14 days as of 2026-05-23 (was 60). The passphrase file is 64 bytes random, mode 600 root:root, never transmitted off-host.
 
The 14-day window describes ACTIVE off-host storage: after 14 days the encrypted bundle is removed from the active view of the off-host provider by the nightly rotation. The current off-host provider (Dropbox Pro) additionally retains deleted files in a deletion-recovery layer for up to 180 days, during which the encrypted bundle may remain recoverable by the account operator; after that window the bundle is permanently and irrecoverably deleted. The bundle is GPG-AES256 encrypted at all times; the off-host provider cannot read it under any circumstance. This active-vs-recovery distinction is queued for removal once a B-side backend migration (Hetzner Storage Box BX11, SFTP, POSIX unlink, no recovery layer) ships; tracked in ''Honest limitations'' below.


The backup tar covers <code>/var/www/mediawiki/images</code>, <code>LocalSettings.php</code>, the Pharmacopedia extension, and local skin assets. The full <code>mediawiki</code> schema is dumped separately to a sibling SQL file (also encrypted). Deliberately excluded by path: the AdminCrypto master key, the OAuth2 RSA private key, the backup passphrase itself, the Gmail SMTP credential, and the Let's Encrypt private key. ''A leak of any single backup bundle does not yield Mode B decryption power, JWT signing power, or further off-host backup decryption — those keys live elsewhere on the host and are not in the bundle.''
The backup tar covers <code>/var/www/mediawiki/images</code>, <code>LocalSettings.php</code>, the Pharmacopedia extension, and local skin assets. The full <code>mediawiki</code> schema is dumped separately to a sibling SQL file (also encrypted). Deliberately excluded by path: the AdminCrypto master key, the OAuth2 RSA private key, the backup passphrase itself, the Gmail SMTP credential, and the Let's Encrypt private key. ''A leak of any single backup bundle does not yield Mode B decryption power, JWT signing power, or further off-host backup decryption — those keys live elsewhere on the host and are not in the bundle.''
Line 226: Line 231:


* Cloudflare Turnstile gates account creation, repeated failed logins, URL-bearing edits, and email-sending. Editors are not challenged on normal edits (a deliberate friction trade-off).
* Cloudflare Turnstile gates account creation, repeated failed logins, URL-bearing edits, and email-sending. Editors are not challenged on normal edits (a deliberate friction trade-off).
* The AbuseFilter extension is installed and configured; the active-rule set is small today and the lane will grow as live-fire patterns emerge.
* The AbuseFilter extension is loaded with 2 rules, both enabled as of 0.9.8.7. Rule 1: spam-keyword block (block + disallow). Rule 2: new-account edit throttle (throttle). The active rule set is small; the lane will grow as live-fire patterns emerge.
* Every file or image upload is scanned by ClamAV before the file moves into the persistent store. The scanner is run via <code>clamdscan</code> (persistent daemon, fast) with a fallback to <code>clamscan</code>. The gate is fail-closed: exit 0 = clean (proceed), exit 1 = infected (reject + unlink), any other exit = error (reject + unlink). A scanner crash never becomes a pass.
* Every file or image upload is scanned by ClamAV before the file moves into the persistent store. The scanner is run via <code>clamdscan</code> (persistent daemon, fast) with a fallback to <code>clamscan</code>. The gate is fail-closed: exit 0 = clean (proceed), exit 1 = infected (reject + unlink), any other exit = error (reject + unlink). A scanner crash never becomes a pass.
* fail2ban (see ''SSH + host'' above) bans abusive IPs at the network layer.
* fail2ban (see ''SSH + host'' above) bans abusive IPs at the network layer.
Line 236: Line 241:


* '''Single point of failure.''' Host-root compromise yields AdminCrypto Mode B decryption, OAuth2 JWT signing, backup decryption (via the passphrase file), and outbound email impersonation. Defense-in-depth lives in per-key file separation + filesystem perms + open_basedir, but a root-on-host attacker who clears each gate gets everything.
* '''Single point of failure.''' Host-root compromise yields AdminCrypto Mode B decryption, OAuth2 JWT signing, backup decryption (via the passphrase file), and outbound email impersonation. Defense-in-depth lives in per-key file separation + filesystem perms + open_basedir, but a root-on-host attacker who clears each gate gets everything.
* '''Backup-lag on deletion.''' When a user requests deletion, the live row is purged; encrypted backups containing the deleted data roll off naturally over their retention window (up to 60 days off-host). Disclosed in About:Privacy.
* '''Backup-lag on deletion.''' When a user requests deletion, the live row is purged immediately; the encrypted bundle is removed from active off-host storage after 14 days; the off-host provider's deletion-recovery layer may keep the (still-encrypted) bundle recoverable by the account operator for up to 180 days after that, until it is permanently deleted. Disclosed in About:Privacy on the wiki and (with identical wording) in Oyami's PRIVACY.md. Queued: a backend migration to Hetzner Storage Box (POSIX unlink, no recovery layer) collapses the window to a clean 14 days.
* '''No CDN, no DDoS layer.''' One VM, three open ports, UFW + fail2ban. Hostile traffic can take the site down; it cannot exfiltrate.
* '''No CDN, no DDoS layer.''' One VM, three open ports, UFW + fail2ban. Hostile traffic can take the site down; it cannot exfiltrate.
* '''Some application-layer key rotation is "never" by design.''' The voter-hash secret and the AdminCrypto Mode B master key cannot rotate without breaking either anonymity or existing wrappings respectively. Loss of either key has the obvious one-time consequence; trading that off against the alternative (re-encrypt every historical record) was the deliberate choice.
* '''Some application-layer key rotation is "never" by design.''' The voter-hash secret and the AdminCrypto Mode B master key cannot rotate without breaking either anonymity or existing wrappings respectively. Loss of either key has the obvious one-time consequence; trading that off against the alternative (re-encrypt every historical record) was the deliberate choice.
* '''Two enabled AbuseFilter rules.''' The pipeline is in place; the rule set is small. Honest signal: this is plumbing for future use, not active filtering today.
* '''TLS allows TLSv1.0 and TLSv1.1.''' The live Apache literal is <code>SSLProtocol all -SSLv3</code>, which does not exclude older TLS protocol versions at the protocol layer. Hardening to an explicit modern cipher list with <code>SSLProtocol -TLSv1 -TLSv1.1</code> is queued.
* '''The perspective-invite token is cleartext at rest in <code>pcp_perspective_invite.pvi_token</code>.''' Hash-on-store migration is queued (interface-claude + parser-claude lane). Severity: an attacker with DB read can submit a perspective under a planted invite identity; not access to medical data.
* '''Perspective-invite tokens are half-migrated to hashed storage.''' 0.9.8.7 added the <code>pvi_token_hash</code> column, backfilled it, and switched the lookup to hash-first; the cleartext <code>pvi_token</code> column is still present so the dual-write fallback works during the deploy edge. 0.9.8.8 drops the cleartext column and the fallback branch, at which point a database-read attacker can no longer extract usable invite tokens. Severity in the interim: an attacker with DB read can still submit a perspective under a planted invite identity; not access to medical data.


=== Security researchers welcome ===
=== Security researchers welcome ===