# Copyright (C) 2015 Alex-Daniel Jakimenko # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU General Public License as published by the Free Software # Foundation, either version 3 of the License, or (at your option) any later # version. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along with # this program. If not, see . use strict; # use warnings; use v5.10; use Crypt::Rijndael; use Crypt::Random::Seed; AddModuleDescription('private-wiki.pl', 'Private Wiki Extension'); our ($q, $FS, @IndexList, %IndexHash, $IndexFile, $TempDir, $KeepDir, %LockCleaners, $ShowAll); my ($cipher, $random); my $PrivateWikiInitialized = ''; sub PrivateWikiInit { return if $PrivateWikiInitialized; $PrivateWikiInitialized = 1; if (UserIsEditor()) { # keysize() is 32, but 24 and 16 are also possible, blocksize() is 16 my $pass = GetParam('pwd'); $cipher = Crypt::Rijndael->new(pack("H*", GetParam('pwd')), Crypt::Rijndael::MODE_CBC()); # TODO print error if the password Is not in hex? # We are using /dev/urandom (or other nonblocking source) because we don't want # to make our users wait for a couple of minutes until we get our numbers... $random = Crypt::Random::Seed->new(NonBlocking => 1) // die "No random sources exist"; } } sub PadTo16Bytes { # use this only on bytes (after encode_utf8) my ($data, $minLength) = @_; my $endBytes = length($data) % 16; $data .= "\0" x (16 - $endBytes) if $endBytes != 0; $data .= "\0" x ($minLength - length $data) if $minLength; return $data; } my $errorMessage = T('This error should not happen. If your password is set correctly and you are still' . ' seeing this message, then it is a bug, please report it. If you are just a stranger' . ' and trying to get unsolicited access, then keep in mind that all of the data is' . ' encrypted with AES-256 and the key is not stored on the server, good luck.'); *OldPrivateWikiReadFile = \&ReadFile; *ReadFile = \&NewPrivateWikiReadFile; sub NewPrivateWikiReadFile { ReportError(T('Attempt to read encrypted data without a password.'), '403 FORBIDDEN', 0, $q->p($errorMessage)) if not UserIsEditor(); PrivateWikiInit(); my $file = shift; if (open(my $IN, '<', encode_utf8($file))) { local $/ = undef; # Read complete files my $data = <$IN>; close $IN; return (1, '') unless $data; $cipher->set_iv(substr $data, 0, 16); $data = $cipher->decrypt(substr $data, 16); my $copy = $data; # copying is required, see https://github.com/briandfoy/crypt-rijndael/issues/5 $copy =~ s/\0+$//; return (1, decode_utf8($copy)); } return (0, ''); } *OldPrivateWikiWriteStringToFile = \&WriteStringToFile; *WriteStringToFile = \&NewPrivateWikiWriteStringToFile; sub NewPrivateWikiWriteStringToFile { ReportError(T('Attempt to read encrypted data without a password.'), '403 FORBIDDEN', 0, $q->p($errorMessage)) if not UserIsEditor(); PrivateWikiInit(); my ($file, $string) = @_; open(my $OUT, '>', encode_utf8($file)) or ReportError(Ts('Cannot write %s', $file) . ": $!", '500 INTERNAL SERVER ERROR'); my $iv = $random->random_bytes(16); $cipher->set_iv($iv); print $OUT $iv; print $OUT $cipher->encrypt(PadTo16Bytes(encode_utf8($string))); close($OUT); } # TODO is there any better way to append data to encrypted files? sub AppendStringToFile { my ($file, $string) = @_; WriteStringToFile($file, ReadFile($file) . $string); # This should be happening under a lock } # We do not want to store page names in plaintext, let's encrypt them! # Therefore we will rely on the pageidx file. #*OldPrivateWikiRefreshIndex = \&RefreshIndex; *RefreshIndex = \&NewPrivateWikiRefreshIndex; sub NewPrivateWikiRefreshIndex { if (not IsFile($IndexFile)) { # Index file does not exist yet, this is a new wiki my $fh; open($fh, '>', encode_utf8($IndexFile)) or die "Unable to open file $IndexFile : $!"; # 'touch' equivalent close($fh) or die "Unable to close file : $IndexFile $!"; return; } return; #ReportError(T('Cannot refresh index.'), '500 Internal Server Error', 0, #$q->p('If you see this message, then there is a bug, please report it. ' #. 'Normally Private Wiki Extension should prevent attempts to refresh the index, but this time something weird has happened.')); } our %PageIvs = (); #*OldPrivateWikiReadIndex = \&ReadIndex; *ReadIndex = \&NewPrivateWikiReadIndex; sub NewPrivateWikiReadIndex { my ($status, $rawIndex) = ReadFile($IndexFile); # not fatal if ($status) { my @rawPageList = split(/ /, $rawIndex); for (@rawPageList) { my ($pageName, $iv) = split /!/, $_, 2; push @IndexList, $pageName; $PageIvs{$pageName} = pack "H*", $iv; # decode hex string } %IndexHash = map {$_ => 1} @IndexList; return @IndexList; } return; } #*OldPrivateWikiWriteIndex = \&WriteIndex; *WriteIndex = \&NewPrivateWikiWriteIndex; sub NewPrivateWikiWriteIndex { WriteStringToFile($IndexFile, join(' ', map { $_ . '!' . unpack "H*", $PageIvs{$_} } @IndexList)); } # pages longer than 6 blocks will result in filenames that are longer than 255 bytes our $PageNameLimit = 96; sub GetPrivatePageFile { my ($id) = @_; PrivateWikiInit(); my $iv = $PageIvs{$id}; if (not $iv) { # generate iv for new pages. It is okay if we are not called from SavePage, because # in that case the caller will probably check if that file exists (and it clearly does not) $iv = $random->random_bytes(16); $PageIvs{$id} = $iv; } $cipher->set_iv($iv); # We cannot use full byte range because of the filesystem limits my $returnName = unpack "H*", $iv . $cipher->encrypt(PadTo16Bytes(encode_utf8($id), 96)); # to hex string return $returnName; } *OldPrivateWikiGetPageFile = \&GetPageFile; *GetPageFile = \&NewPrivateWikiGetPageFile; sub NewPrivateWikiGetPageFile { OldPrivateWikiGetPageFile(GetPrivatePageFile @_); } *OldPrivateWikiGetKeepDir = \&GetKeepDir; *GetKeepDir = \&NewPrivateWikiGetKeepDir; sub NewPrivateWikiGetKeepDir { OldPrivateWikiGetKeepDir(GetPrivatePageFile @_); } # Now let's do some hacks! # First of all, "ban" all users so they can't see anything # (Note: they will not see anything anyway, since the pages will only # get decrypted when the user provides correct password) our $BannedCanRead = 0; sub UserIsBanned { return GetParam('action', '') ne 'password'; # login is always ok } # Oddmuse attempts to read pageidx file sometimes. If the password is not set let's just skip it *OldPrivateWikiAllPagesList = \&AllPagesList; *AllPagesList = \&NewPrivateWikiAllPagesList; our @MyInitVariables; push(@MyInitVariables, \&AllPagesList); sub NewPrivateWikiAllPagesList { return () if not UserIsEditor(); # no key - no AllPagesList OldPrivateWikiAllPagesList(@_); } # Then, let's allow DoDiff to save stuff in unencrypted form so that it can be diffed. # We will wipe the files right after the diff action. # This sub is copied from the core. Lines marked with CHANGED were changed. sub DoDiff { # Actualy call the diff program CreateDir($TempDir); my $oldName = "$TempDir/old"; my $newName = "$TempDir/new"; RequestLockDir('diff') or return ''; $LockCleaners{'diff'} = sub { Unlink($oldName) if IsFile($oldName); Unlink($newName) if IsFile($newName); }; OldPrivateWikiWriteStringToFile($oldName, $_[0]); # CHANGED Here we use the old sub! OldPrivateWikiWriteStringToFile($newName, $_[1]); # CHANGED my $diff_out = decode_utf8(`diff -- \Q$oldName\E \Q$newName\E`); $diff_out =~ s/\n\K\\ No newline.*\n//g; # Get rid of common complaint. # CHANGED We have to unlink the files because we don't want to store them in plaintext! Unlink($oldName, $newName); # CHANGED ReleaseLockDir('diff'); return $diff_out; } # Same thing has to be done with MergeRevisions # This sub is copied from the core. Lines marked with CHANGED were changed. sub MergeRevisions { # merge change from file2 to file3 into file1 my ($file1, $file2, $file3) = @_; my ($name1, $name2, $name3) = ("$TempDir/file1", "$TempDir/file2", "$TempDir/file3"); CreateDir($TempDir); RequestLockDir('merge') or return T('Could not get a lock to merge!'); $LockCleaners{'merge'} = sub { # CHANGED Unlink($name1) if IsFile($name1); Unlink($name2) if IsFile($name2); Unlink($name3) if IsFile($name3); }; OldPrivateWikiWriteStringToFile($name1, $file1); # CHANGED OldPrivateWikiWriteStringToFile($name2, $file2); # CHANGED OldPrivateWikiWriteStringToFile($name3, $file3); # CHANGED my ($you, $ancestor, $other) = (T('you'), T('ancestor'), T('other')); my $output = decode_utf8(`diff3 -m -L \Q$you\E -L \Q$ancestor\E -L \Q$other\E -- \Q$name1\E \Q$name2\E \Q$name3\E`); Unlink($name1, $name2, $name3); # CHANGED unlink temp files -- we don't want to store them in plaintext! ReleaseLockDir('merge'); return $output; } # Surge protection has to be unencrypted because in the context of this module # it is a tool against people who have no password set (thus we have no key # to do encryption). our ($VisitorFile, %RecentVisitors, $Now, $SurgeProtectionTime, $SurgeProtectionViews); # This sub is copied from the core. Lines marked with CHANGED were changed. sub ReadRecentVisitors { my ($status, $data) = OldPrivateWikiReadFile($VisitorFile); # CHANGED %RecentVisitors = (); return unless $status; foreach (split(/\n/, $data)) { my @entries = split /$FS/; my $name = shift(@entries); $RecentVisitors{$name} = \@entries if $name; } } # This sub is copied from the core. Lines marked with CHANGED were changed. sub WriteRecentVisitors { my $data = ''; my $limit = $Now - $SurgeProtectionTime; foreach my $name (keys %RecentVisitors) { my @entries = @{$RecentVisitors{$name}}; if ($entries[0] >= $limit) { # if the most recent one is too old, do not keep $data .= join($FS, $name, @entries[0 .. $SurgeProtectionViews - 1]) . "\n"; } } OldPrivateWikiWriteStringToFile($VisitorFile, $data); # CHANGED } # At the same time, we don't want to store any information about the editors # because it reveals their usernames. A bit paranoidal, but why not. *OldPrivateWikiAddRecentVisitor = \&AddRecentVisitor; *AddRecentVisitor = \&NewPrivateWikiAddRecentVisitor; sub NewPrivateWikiAddRecentVisitor { return if UserIsEditor(); OldPrivateWikiAddRecentVisitor(@_); } *OldPrivateWikiDelayRequired = \&DelayRequired; *DelayRequired = \&NewPrivateWikiDelayRequired; sub NewPrivateWikiDelayRequired { return '' if UserIsEditor(); OldPrivateWikiDelayRequired(@_); } # PageIsUploadedFile attempts to read the file partially, which does not work that # well on encrypted data. Therefore, we disable file uploads for now. our $UploadAllowed = 0; sub PageIsUploadedFile { '' } # Finally, we have to fix RecentChanges our ($RcDefault, $RcFile, $RcOldFile, $FreeLinkPattern, $LinkPattern, $ShowEdits, $PageCluster); # This sub is copied from the core. Lines marked with CHANGED were changed. sub GetRcLines { # starttime, hash of seen pages to use as a second return value my $starttime = shift || GetParam('from', 0) || $Now - GetParam('days', $RcDefault) * 86400; # 24*60*60 my $filterOnly = GetParam('rcfilteronly', ''); # these variables apply accross logfiles my %match = $filterOnly ? map { $_ => 1 } SearchTitleAndBody($filterOnly) : (); my %following = (); my @result = (); # check the first timestamp in the default file, maybe read old log file my $filelike = ReadFile($RcFile); # CHANGED open my $F, '<:encoding(UTF-8)', \$filelike or die $!; # CHANGED my $line = <$F>; my ($ts) = split(/$FS/, $line); # the first timestamp in the regular rc file if (not $ts or $ts > $starttime) { # we need to read the old rc file, too push(@result, GetRcLinesFor($RcOldFile, $starttime, \%match, \%following)); } push(@result, GetRcLinesFor($RcFile, $starttime, \%match, \%following)); # GetRcLinesFor is trying to save memory space, but some operations # can only happen once we have all the data. return LatestChanges(StripRollbacks(@result)); } # This sub is copied from the core. Lines marked with CHANGED were changed. sub GetRcLinesFor { my $file = shift; my $starttime = shift; my %match = %{$_[0]}; # deref my %following = %{$_[1]}; # deref # parameters my $showminoredit = GetParam('showedit', $ShowEdits); # show minor edits my $all = GetParam('all', $ShowAll); my ($idOnly, $userOnly, $hostOnly, $clusterOnly, $filterOnly, $match, $lang, $followup) = map { UnquoteHtml(GetParam($_, '')); } qw(rcidonly rcuseronly rchostonly rcclusteronly rcfilteronly match lang followup); # parsing and filtering my @result = (); my $filelike = ReadFile($file); # CHANGED open my $F, '<:encoding(UTF-8)', \$filelike or return (); # CHANGED while (my $line = <$F>) { chomp($line); my ($ts, $id, $minor, $summary, $host, $username, $revision, $languages, $cluster) = split(/$FS/, $line); next if $ts < $starttime; $following{$id} = $ts if $followup and $followup eq $username; next if $followup and (not $following{$id} or $ts <= $following{$id}); next if $idOnly and $idOnly ne $id; next if $filterOnly and not $match{$id}; next if ($userOnly and $userOnly ne $username); next if $minor == 1 and not $showminoredit; # skip minor edits (if [[rollback]] this is bogus) next if not $minor and $showminoredit == 2; # skip major edits next if $match and $id !~ /$match/i; next if $hostOnly and $host !~ /$hostOnly/i; my @languages = split(/,/, $languages); next if $lang and @languages and not grep(/$lang/, @languages); if ($PageCluster) { ($cluster, $summary) = ($1, $2) if $summary =~ /^\[\[$FreeLinkPattern\]\] ?: *(.*)/ or $summary =~ /^$LinkPattern ?: *(.*)/; next if ($clusterOnly and $clusterOnly ne $cluster); $cluster = '' if $clusterOnly; # don't show cluster if $clusterOnly eq $cluster if ($all < 2 and not $clusterOnly and $cluster) { $summary = "$id: $summary"; # print the cluster instead of the page $id = $cluster; $revision = ''; } } else { $cluster = ''; } $following{$id} = $ts if $followup and $followup eq $username; push(@result, [$ts, $id, $minor, $summary, $host, $username, $revision, \@languages, $cluster]); } return @result; } # We do not want to print the header to unauthorized users because it contains # the gotobar, our logo and a useless search form. *OldPrivateWikiGetHeaderDiv = \&GetHeaderDiv; *GetHeaderDiv = \&NewPrivateWikiGetHeaderDiv; sub NewPrivateWikiGetHeaderDiv { return OldPrivateWikiGetHeaderDiv(@_) if UserIsEditor(); my ($id, $title, $oldId, $embed) = @_; my $result .= $q->start_div({-class=>'header'}); our $Message; $result .= $q->div({-class=>'message'}, $Message) if $Message; $result .= GetHeaderTitle($id, $title, $oldId); $result .= $q->end_div(); return $result; }