About:Pharmacopedia.ext: Difference between revisions
From Pharmacopedia
More actions
| [checked revision] | [checked revision] |
MDElliottMD (talk | contribs) Drop wikitext H1 so the orientation block becomes the lead section and == sections collapse independently on mobile (per Mark, 2026-05-23) |
MDElliottMD (talk | contribs) 0.9.8.7 close-out: Security & encryption refresh -- M3 perspective-invite hashing subsection added, backups section names 14-day active + up-to-180-day recovery-layer, honest limitations updated, OAuth 2.0 subsection notes iOS-autofill autocomplete attrs |
||
| Line 1: | Line 1: | ||
'''Version:''' 0.9.8. | '''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 200: | Line 200: | ||
==== 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/<token></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 219: | ||
--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 | 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 236: | Line 244: | ||
* '''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 | * '''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. | * '''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. | ||
* ''' | * '''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 === | ||