diff --git a/Dockerfile b/Dockerfile index 9bbda9e..c4b5d74 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,26 @@ from alpine:latest -run apk add wget gnupg sqlite unzip build-base libmagic file-dev perl perl-dev perl-app-cpanminus -run cpanm --notest IPC::Run DBD::SQLite Net::DNS::Resolver Crypt::Eksblowfish::Bcrypt JSON URI::Escape HTML::Entities Net::Server HTTP::Server::Simple HTTP::Server::Simple::Static Crypt::Random +run apk add wget libsodium libsodium-dev cmake pkgconfig sqlite unzip build-base libmagic file-dev perl perl-dev perl-app-cpanminus + +run cpanm --notest IPC::Run DBD::SQLite Net::DNS::Resolver Crypt::Eksblowfish::Bcrypt JSON URI::Escape HTTP::Accept Net::Server HTTP::Server::Simple HTTP::Server::Simple::Static Crypt::Random + +run mkdir -p /tmp/minisign && \ + cd /tmp/minisign && \ + wget -O minisign.zip https://github.com/jedisct1/minisign/archive/master.zip && \ + unzip minisign.zip && \ + cd minisign-master && \ + mkdir build && \ + cd build && \ + cmake .. && \ + make && \ + make install run mkdir -p /opt/data/plans copy schema.sql /opt/data run cat /opt/data/schema.sql | sqlite3 /opt/data/users.db run rm /opt/data/schema.sql -run apk del build-base perl-dev perl-app-cpanminus wget sqlite unzip file-dev +run apk del build-base perl-dev perl-app-cpanminus wget sqlite unzip file-dev cmake pkgconfig libsodium-dev copy server.pl /opt workdir /opt diff --git a/README.md b/README.md index 0cd2fee..2547e11 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,33 @@ -# dotplan.online +# Dotplan ## The un-social network. - User-provided content tied to an email address. -- Text only, limited to 4kb. -- No retweets, shares, @s, likes, or boosting of any kind. -- Authenticity optionally verified by clients using public PGP keys. +- Text only. +- No re-tweets, shares, @s, likes, or boosting of any kind. +- Authenticity optionally verified by clients using OpenBSD signify/[Minisign](https://jedisct1.github.io/minisign/). - Accessed via public APIs. - Open source. - Self-hostable, discovery via domain SRV records. ## API -Any dotplan implementation should expose at least the following two endpoints: +Any Dotplan implementation should expose at least the following endpoint and behavior: - `GET /plan/{email}` - retrieve a plan - - `text/plain` by default - raw plan content - - `?format=html` or `Accept: text/html` - plan content with html entity encoding for special characters - - `?format=json` or `Accept: application/json`: + - `Accept: text/plain` request header - return raw plan content + - `Accept: application/json` request header - return json plan details: - `plan` - raw plan content - - `signature` - ascii armored PGP signature if this plan was signed - `timestamp` - when this plan was created + - `signature` - optional signature if this plan was signed + - `Last-Modified` response header should indicate when the plan was created + - `X-Dotplan-Pubkey: {base64 signify pubkey}` request header - perform signature verification + - append `X-Dotplan-Verified: true` response header if verification succeeded + - `403` if verification failed or is not supported by the server + - client-side signature verification using the json response should be favored since the server may not be trusted - `404` if no plan found - `301` redirect if domain SRV record indicates plan is on a different dotplan provider - - This is optional for servers to act as relays, in practice the client should look up the SRV record itself -- `POST /verify/{email}` - verify PGP signature of a plan - - request json data: - - `pubkey` - ascii armored public PGP key to verify the signature with - - response json data: - - `verified` - `1` or `0` depending on whether verification of the plan signature was successful - - normal plan details included if `verified=1` - - `403` if server-side verification is not supported - - `404` if no plan found - - `308` redirect if domain SRV record indicates plan is on a different dotplan provider. - - This is optional for servers to act as relays, in practice the client should look up the SRV record itself. + - this is optional for servers to act as relays, client-side SRV lookups should be favored since the server may not be trusted ### Authentication diff --git a/server.pl b/server.pl index 935bfd8..3931371 100644 --- a/server.pl +++ b/server.pl @@ -64,33 +64,38 @@ if (defined $ENV{'LOCAL_DOMAINS'}) { use MIME::Base64 qw(decode_base64); use POSIX qw(strftime); use JSON qw(encode_json decode_json); + use HTTP::Accept; use URI::Escape qw(uri_escape uri_unescape); - use HTML::Entities qw(encode_entities); use File::Spec::Functions qw(catfile); ############### # Common Errors ############### - my $not_found = encode_json({error => 'Not found.'}); - my $not_implemented = encode_json({error => 'Not implemented yet.'}); - my $not_allowed = encode_json({error => 'HTTP method not supported.'}); - my $not_authorized = encode_json({error => 'Not authorized.'}); - my $resp_header = { 200 => 'OK', 301 => 'Moved Permanently', 304 => 'Not Modified', - 308 => 'Permanent Redirect', 400 => 'Bad Request', 401 => 'Unauthorized', + 403 => 'Forbidden', 404 => 'Not Found', 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', 429 => 'Too Many Requests', - 501 => 'Not Implemented', 500 => 'Internal Server Error' }; + my $resp_body = { + 301 => 'Redirecting to the appropriate server for that plan.', + 401 => 'The authorization details provided did not match our records.', + 403 => 'The requested plan signature could not be verified with the specified public key.', + 404 => 'The requested resource was not found.', + 405 => 'The server does not support the specified request method.', + 406 => 'The server does not support any of the requested Content-Types.', + 500 => 'An unexpected error occurred.' + }; + ################# # Request Routing ################# @@ -104,57 +109,83 @@ if (defined $ENV{'LOCAL_DOMAINS'}) { $path =~ s{^https?://([^/:]+)(:\d+)?/}{/}; $cgi->{'.path_info'} = '/index.html' if $path eq '/'; my $method = $cgi->request_method(); - my $host = $cgi->http('X-Forwarded-For') || $cgi->remote_addr(); + my $accept = HTTP::Accept->new($cgi->http('Accept')); + $cgi->param('accept', $accept); + my $body = $cgi->param('POSTDATA') || $cgi->param('PUTDATA'); + if (defined $body) { + eval { + $cgi->param('json-body', decode_json($body)); + }; + if ($@) { + print_json_response($cgi, 400, {error => 'Unable to parse json payload.'}); + return; + } + } else { + $cgi->param('json-body', {}); + } + + my $routes = [ + { + path => qr/^\/plan\/([^\/]{$minimum_email_length,$maximum_email_length})$/, + methods => { + GET => {handler => \&get_plan, valid_types => ['application/json', 'text/plain']}, + HEAD => {handler => \&get_plan, valid_types => ['application/json', 'text/plain']}, + PUT => {handler => \&update_plan, valid_types => ['application/json']} + } + }, + { + path => qr/^\/token$/, + methods => { + GET => {handler => \&get_token, valid_types => ['application/json']}, + DELETE => {handler => \&delete_token, valid_types => ['application/json']} + } + }, + { + path => qr/^\/users\/([^\/]{$minimum_email_length,$maximum_email_length})\/pwchange$/, + methods => { + GET => {handler => \&get_pwtoken, valid_types => ['application/json']}, + PUT => {handler => \&update_password, valid_types => ['application/json']} + } + }, + { + path => qr/^\/users\/([^\/]{$minimum_email_length,$maximum_email_length})$/, + methods => { + POST => {handler => \&create_user, valid_types => ['application/json']}, + PUT => {handler => \&validate_email, valid_types => ['application/json']} + } + }, + ]; eval { util_log("REQ $req_id $method $path"); - if ($method eq 'GET') { - if ($path =~ /^\/token$/) { - get_token($cgi); - } elsif ($path =~ /^\/users\/([^\/]{$minimum_email_length,$maximum_email_length})\/pwchange$/) { - get_pwtoken($1, $cgi); - } elsif ($path =~ /^\/plan\/([^\/]{$minimum_email_length,$maximum_email_length})$/) { - get_plan($1, $cgi); - } elsif (!$self->serve_static($cgi, $webroot)) { - print_response($cgi, 404, $not_found); + + # check for matching handler + foreach my $route(@$routes) { + if ($path =~ $route->{'path'}) { + my $param = $1; + if (defined $route->{'methods'}->{$method}) { + if ($accept->match(@{$route->{'methods'}->{$method}->{'valid_types'}})) { + $route->{'methods'}->{$method}->{'handler'}->($cgi, $param); + return; + } else { + print_response($cgi, 406); + return; + } + } else { + print_response($cgi, 405); + return; + } } - } elsif ($method eq 'HEAD') { - if ($path =~ /^\/plan\/([^\/]{$minimum_email_length,$maximum_email_length})$/) { - get_plan($1, $cgi); - } elsif (!$self->serve_static($cgi, $webroot)) { - print_response($cgi, 404, $not_found); - } - } elsif ($method eq 'POST') { - if ($path =~ /^\/users\/([^\/]{$minimum_email_length,$maximum_email_length})$/) { - create_user($1, $cgi); - } elsif ($path =~ /^\/verify\/([^\/]{$minimum_email_length,$maximum_email_length})$/) { - verify_plan($1, $cgi); - } else { - print_response($cgi, 404, $not_found); - } - } elsif ($method eq 'PUT') { - if ($path =~ /^\/users\/([^\/]{$minimum_email_length,$maximum_email_length})$/) { - validate_email($1, $cgi); - } elsif ($path =~ /^\/users\/([^\/]{$minimum_email_length,$maximum_email_length})\/pwchange$/) { - update_password($1, $cgi); - } elsif ($path =~ /^\/plan\/([^\/]{$minimum_email_length,$maximum_email_length})$/) { - update_plan($1, $cgi); - } else { - print_response($cgi, 404, $not_found); - } - } elsif ($method eq 'DELETE') { - if ($path =~ /^\/token$/) { - delete_token($cgi); - } else { - print_response($cgi, 404, $not_found); - } - } else { - print_response($cgi, 405, $not_allowed); + } + + # if no handler, check for static file + if (!$self->serve_static($cgi, $webroot)) { + print_response($cgi, 404); } }; if ($@) { - print_json_response($cgi, 500, {error => 'An unexpected error occurred.'}); util_log("ERR $req_id $@"); + print_response($cgi, 500); } } @@ -163,45 +194,49 @@ if (defined $ENV{'LOCAL_DOMAINS'}) { ################## sub print_response { - my ($cgi, $code, $body, $type, $redirect, $mtime) = @_; + my ($cgi, $code, $headers, $body) = @_; my $req_id = $cgi->param('request_id'); my $path = $cgi->path_info(); my $method = $cgi->request_method(); - my $host = $cgi->http('X-Forwarded-For') || $cgi->remote_addr(); util_log("RES($code) $req_id $method $path"); - my $header = $resp_header->{$code}; - if (!defined $type) { - $type = 'application/json'; + $headers = {} if !defined $headers; + $headers->{'Content-Type'} = $cgi->param('accept')->match(qw(application/json text/plain)) || 'application/json' if !defined $headers->{'Content-Type'}; + + my $code_description = $resp_header->{$code}; + if (!defined $body && defined $resp_body->{$code}) { + $body = $headers->{'Content-Type'} eq 'application/json' + ? encode_json({error => $resp_body->{$code}}) + : $resp_body->{$code}; } - my $length = length($body); - $body = '' if $cgi->request_method() eq 'HEAD'; - my $content_header = ''; + my $length = defined $body ? length($body) : 0; + $body = '' if !defined $body || $cgi->request_method() eq 'HEAD'; + my $length_header = ''; if ($length > 0) { - $content_header = "\nContent-Type: $type\nContent-Length: $length"; + $length_header = "\nContent-Length: $length"; } my $now = time; my $date = HTTP::Date::time2str($now); - my $redirect_header = ''; - if (defined $redirect) { - $redirect_header = "\nLocation: $redirect"; - } - my $mtime_header = ''; - if (defined $mtime) { - $mtime = $now if $mtime > $now; - $mtime_header = "\nModified-Date: " . HTTP::Date::time2str($mtime); + my $extra_headers = ''; + foreach my $header(keys %$headers) { + my $val = $headers->{$header}; + $extra_headers .= "\n$header: $val"; } print <{'Content-Type'} = 'application/json'; + print_response($cgi, $code, $headers, encode_json($data)); } #################### @@ -210,57 +245,61 @@ EOF ##### POST /users/{email} sub create_user { - my ($email, $cgi) = @_; + my ($cgi, $email) = @_; if ($email !~ /^[^\@]+\@[^\@\.]+\.[^\@]+$/) { - print_json_response($cgi, 400, {error => 'Only email addresses of the form {local}@{domain.tld} are supported.'}); - } else { - my $user = util_get_user($email); - if (defined $user && $user->{'verified'}) { - print_json_response($cgi, 400, {error => 'User already exists.'}); - } elsif (defined $user && defined $user->{'pw_token_expires'} && $user->{'pw_token_expires'} >= time) { - print_json_response($cgi, 429, {error => "Wait $pw_token_expiration_minutes minutes between this type of request."}); - } else { - my $password = util_json_body($cgi)->{'password'}; - if (!defined $password || length($password) < $minimum_password_length) { - print_json_response($cgi, 400, {error => "Password must be at least $minimum_password_length characters long."}); - } else { - my $query = (defined $user) - ? "UPDATE users SET password=?, pw_token=?, pw_token_expires=datetime('now', '+$pw_token_expiration_minutes minutes') WHERE email=?" - : "INSERT INTO users (password, pw_token, pw_token_expires, email) values (?, ?, datetime('now', '+$pw_token_expiration_minutes minutes'), ?)"; - my $crypted = util_bcrypt($password); - my $sth = util_get_dbh()->prepare($query); - my $token = util_token(24); - $sth->execute($crypted, $token, $email); - die $sth->errstr if $sth->err; - util_sendmail($email, '[DOTPLAN] Verify your email', - "Please verify your email address.\n" . - "Click the following link or copy it into your browser:\n" . - "https://$hostname/verify.html?token=$token"); - print_json_response($cgi, 200, {email => $email}); - } - } + print_json_response($cgi, 400, {error => 'Only email addresses of the form {local}@{domain.tld} are supported by this server.'}); + return; } + my $user = util_get_user($email); + if (defined $user && $user->{'verified'}) { + print_json_response($cgi, 400, {error => 'User already exists.'}); + return; + } + if (defined $user && defined $user->{'pw_token_expires'} && $user->{'pw_token_expires'} >= time) { + print_json_response($cgi, 429, {error => "Wait $pw_token_expiration_minutes minutes between this type of request."}); + return; + } + my $password = $cgi->param('json-body')->{'password'}; + if (!defined $password || length($password) < $minimum_password_length) { + print_json_response($cgi, 400, {error => "Password must be at least $minimum_password_length characters long."}); + return; + } + my $query = (defined $user) + ? "UPDATE users SET password=?, pw_token=?, pw_token_expires=datetime('now', '+$pw_token_expiration_minutes minutes') WHERE email=?" + : "INSERT INTO users (password, pw_token, pw_token_expires, email) values (?, ?, datetime('now', '+$pw_token_expiration_minutes minutes'), ?)"; + my $crypted = util_bcrypt($password); + my $sth = util_get_dbh()->prepare($query); + my $token = util_token(24); + $sth->execute($crypted, $token, $email); + die $sth->errstr if $sth->err; + util_sendmail($email, '[DOTPLAN] Verify your email', + "Please verify your email address.\n" . + "Click the following link or copy it into your browser:\n" . + "https://$hostname/verify.html?token=$token"); + print_json_response($cgi, 200, {email => $email}); } - ##### GET /users/{email}?token={token} + ##### PUT /users/{email} sub validate_email { - my ($email, $cgi) = @_; - my $token = util_json_body($cgi)->{'token'}; + my ($cgi, $email) = @_; + my $token = $cgi->param('json-body')->{'token'}; if (!defined $token) { print_json_response($cgi, 400, {error => 'Missing token.'}); - } else { - my $user = util_get_user($email); - if (!defined $user || $user->{'verified'}) { - print_response($cgi, 404, $not_found); - } elsif ($user->{'pw_token'} ne $token) { - print_response($cgi, 401, $not_authorized); - } else { - my $sth = util_get_dbh()->prepare('UPDATE users SET verified=1, pw_token=null, pw_token_expires=null WHERE email=?'); - $sth->execute($email); - die $sth->errstr if $sth->err; - print_json_response($cgi, 200, {success => 1}); - } + return; } + my $user = util_get_user($email); + if (!defined $user || $user->{'verified'}) { + print_response($cgi, 404); + return; + } + if ($user->{'pw_token'} ne $token) { + print_response($cgi, 401); + return; + } + my $sth = util_get_dbh()->prepare('UPDATE users SET verified=1, pw_token=null, pw_token_expires=null WHERE email=?'); + $sth->execute($email); + die $sth->errstr if $sth->err; + print_json_response($cgi, 200, {success => 1}); } ##### GET /token @@ -268,22 +307,22 @@ EOF my $cgi = shift; my $user = util_get_authenticated($cgi); if (!defined $user) { - print_response($cgi, 401, $not_authorized); - } else { - my $sth = util_get_dbh()->prepare("UPDATE users SET token=?, token_expires=datetime('now', ?) WHERE email=?"); - my $token = util_token(24); - my $expires = $cgi->param('expires'); - my $minutes = $auth_token_default_expiration_minutes; - if (defined $expires && $expires =~ /^\d+$/) { - $minutes = int($expires); - if ($minutes <= 0) { - $minutes = $auth_token_default_expiration_minutes; - } - } - $sth->execute($token, "+$minutes minutes", $user->{'email'}); - die $sth->errstr if $sth->err; - print_json_response($cgi, 200, {token => $token}); + print_response($cgi, 401); + return; } + my $sth = util_get_dbh()->prepare("UPDATE users SET token=?, token_expires=datetime('now', ?) WHERE email=?"); + my $token = util_token(24); + my $expires = $cgi->param('expires'); + my $minutes = $auth_token_default_expiration_minutes; + if (defined $expires && $expires =~ /^\d+$/) { + $minutes = int($expires); + if ($minutes <= 0) { + $minutes = $auth_token_default_expiration_minutes; + } + } + $sth->execute($token, "+$minutes minutes", $user->{'email'}); + die $sth->errstr if $sth->err; + print_json_response($cgi, 200, {token => $token}); } ##### DELETE /token @@ -291,157 +330,140 @@ EOF my $cgi = shift; my $user = util_get_authenticated($cgi); if (!defined $user) { - print_response($cgi, 401, $not_authorized); - } else { - my $sth = util_get_dbh()->prepare('UPDATE users SET token=null, token_expires=null WHERE email=?'); - $sth->execute($user->{'email'}); - die $sth->errstr if $sth->err; - print_json_response($cgi, 200, {success => 1}); + print_response($cgi, 401); + return; } + my $sth = util_get_dbh()->prepare('UPDATE users SET token=null, token_expires=null WHERE email=?'); + $sth->execute($user->{'email'}); + die $sth->errstr if $sth->err; + print_json_response($cgi, 200, {success => 1}); } - ##### GET /users/{email}/pwtoken + ##### GET /users/{email}/pwchange sub get_pwtoken { - my ($email, $cgi) = @_; + my ($cgi, $email) = @_; my $user = util_get_user($email); if (!defined $user || !$user->{'verified'}) { - print_response($cgi, 404, $not_found); - } elsif (defined $user->{'pw_token_expires'} && $user->{'pw_token_expires'} >= time) { - print_json_response($cgi, 429, {error => "Wait $pw_token_expiration_minutes between this type of request."}); - } else { - my $token = util_token(24); - my $sth = util_get_dbh()->prepare("UPDATE users SET pw_token=?, pw_token_expires=datetime('now', '+10 minutes') WHERE email=?"); - $sth->execute($token, $email); - die $sth->errstr if $sth->err; - util_sendmail($email, '[DOTPLAN] Password reset request', - "Someone (hopefully you) has requested to change your password.\n" . - "If it wasn't you, you can ignore and delete this email.\n\n" . - "Otherwise, click the following link or copy it into your browser:\n" . - "https://$hostname/change-password.html?token=$token"); - print_json_response($cgi, 200, {success => 1}); + print_response($cgi, 404); + return; } + if (defined $user->{'pw_token_expires'} && $user->{'pw_token_expires'} >= time) { + print_json_response($cgi, 429, {error => "Wait $pw_token_expiration_minutes between this type of request."}); + return; + } + my $token = util_token(24); + my $sth = util_get_dbh()->prepare("UPDATE users SET pw_token=?, pw_token_expires=datetime('now', '+10 minutes') WHERE email=?"); + $sth->execute($token, $email); + die $sth->errstr if $sth->err; + util_sendmail($email, '[DOTPLAN] Password reset request', + "Someone (hopefully you) has requested to change your password.\n" . + "If it wasn't you, you can ignore and delete this email.\n\n" . + "Otherwise, click the following link or copy it into your browser:\n" . + "https://$hostname/change-password.html?token=$token"); + print_json_response($cgi, 200, {success => 1}); } - ##### PUT /users/{email} + ##### PUT /users/{email}/pwchange sub update_password { - my ($email, $cgi) = @_; + my ($cgi, $email) = @_; my $user = util_get_user($email); if (!defined $user || !$user->{'verified'}) { - print_response($cgi, 404, $not_found); - } else { - my $body = util_json_body($cgi); - my $password = $body->{'password'}; - my $pwtoken = $body->{'token'}; - if (!defined $pwtoken || !defined $user->{'pw_token'} || !defined $user->{'pw_token_expires'} || $pwtoken ne $user->{'pw_token'} || $user->{'pw_token_expires'} < time) { - print_json_response($cgi, 400, {error => 'Bad or expired token.'}); - } elsif (!defined $password || length($password) < $minimum_password_length) { - print_json_response($cgi, 400, {error => "Password must be at least $minimum_password_length characters long."}); - } else { - my $crypted = util_bcrypt($password); - my $sth = util_get_dbh()->prepare('UPDATE users SET password=?, pw_token=null, pw_token_expires=null, token=null, token_expires=null WHERE email=?'); - $sth->execute($crypted, $email); - die $sth->errstr if $sth->err; - print_json_response($cgi, 200, {success => 1}); - } + print_response($cgi, 404); + return; } + my $body = $cgi->param('json-body'); + my $password = $body->{'password'}; + my $pwtoken = $body->{'token'}; + if (!defined $pwtoken || !defined $user->{'pw_token'} || !defined $user->{'pw_token_expires'} || $pwtoken ne $user->{'pw_token'} || $user->{'pw_token_expires'} < time) { + print_json_response($cgi, 400, {error => 'Bad or expired token.'}); + return; + } + if (!defined $password || length($password) < $minimum_password_length) { + print_json_response($cgi, 400, {error => "Password must be at least $minimum_password_length characters long."}); + return; + } + my $crypted = util_bcrypt($password); + my $sth = util_get_dbh()->prepare('UPDATE users SET password=?, pw_token=null, pw_token_expires=null, token=null, token_expires=null WHERE email=?'); + $sth->execute($crypted, $email); + die $sth->errstr if $sth->err; + print_json_response($cgi, 200, {success => 1}); } ##### PUT /plan/{email} sub update_plan { - my ($email, $cgi) = @_; + my ($cgi, $email) = @_; my $user = util_get_user($email); if (!defined $user || !$user->{'verified'}) { - print_response($cgi, 404, $not_found); - } else { - my $body = util_json_body($cgi); - my $plan = $body->{'plan'}; - my $signature = $body->{'signature'}; - my $token = $body->{'auth'}; - if (!defined $user->{'token'} || !defined $user->{'token_expires'} || !defined $token || $token ne $user->{'token'} || $user->{'token_expires'} < time) { - print_response($cgi, 401, $not_authorized); - } elsif (defined $plan && length($plan) > $maximum_plan_length) { - print_json_response($cgi, 400, {error => "Plan exceeds maximum length of $maximum_plan_length."}); - } elsif (defined $signature && length($signature) > $maximum_signature_length) { - print_json_response($cgi, 400, {error => "Signature exceeds maximum length of $maximum_signature_length."}); - } else { - util_save_plan($email, $plan, $signature); - print_json_response($cgi, 200, {success => 1}); - } + print_response($cgi, 404); + return; } + my $body = $cgi->param('json-body'); + my $plan = $body->{'plan'}; + my $signature = $body->{'signature'}; + my $token = $body->{'auth'}; + if (!defined $user->{'token'} || !defined $user->{'token_expires'} || !defined $token || $token ne $user->{'token'} || $user->{'token_expires'} < time) { + print_response($cgi, 401); + return; + } + if (defined $plan && length($plan) > $maximum_plan_length) { + print_json_response($cgi, 400, {error => "Plan exceeds maximum length of $maximum_plan_length."}); + return; + } + if (defined $signature && length($signature) > $maximum_signature_length) { + print_json_response($cgi, 400, {error => "Signature exceeds maximum length of $maximum_signature_length."}); + return; + } + util_save_plan($email, $plan, $signature); + print_json_response($cgi, 200, {success => 1}); } ##### GET /plan/{email} sub get_plan { - my ($email, $cgi) = @_; - - my $format = util_get_response_format($cgi); - my $plan = util_get_plan($email); - - if (defined $plan && defined $plan->{'redirect'}) { - # found external plan service, redirect request - print_response($cgi, 301, encode_json({location => $plan->{'redirect'}}), 'application/json', $plan->{'redirect'}); - } elsif (defined $plan) { - # found local plan, check modified - my $now = time; - my $mtime = $plan->{'mtime'}; - my $ifmod = $cgi->http('If-Modified-Since'); - my $ifmtime = HTTP::Date::str2time($ifmod) if defined $ifmod; - if (defined $mtime && defined $ifmtime && $ifmtime <= $now && $mtime <= $ifmtime) { - print_response($cgi, 304, '', $format, undef, $mtime); - } else { - # render response - my $body; - delete $plan->{'mtime'}; - if ($format eq 'application/json') { - $body = encode_json($plan); - } elsif ($format eq 'text/html') { - $body = encode_entities($plan->{'plan'}); - $body =~ s/\n/
\n/g; - } else { - $body = $plan->{'plan'}; - } - print_response($cgi, 200, $body, $format, undef, $mtime); - } - } else { - if ($format eq 'application/json') { - print_response($cgi, 404, $not_found); - } elsif ($format eq 'text/html') { - print_response($cgi, 404, '', 'text/html'); - } else { - print_response($cgi, 404, '', 'text/plain'); - } - } - } - - ##### POST /verify/{email} - sub verify_plan { - my ($email, $cgi) = @_; + my ($cgi, $email) = @_; my $plan = util_get_plan($email); if (defined $plan && defined $plan->{'redirect'}) { # found external plan service, redirect request - print_response($cgi, 308, encode_json({location => $plan->{'redirect'}}), 'application/json', $plan->{'redirect'}); - } elsif (defined $plan) { - my $pubkey = util_json_body($cgi)->{'pubkey'}; - if (!defined $pubkey || !defined $plan->{'signature'}) { - print_json_response($cgi, 200, {verified => 0}); - } elsif (length($pubkey) > $maximum_pubkey_length) { - print_json_response($cgi, 400, {error => "Pubkey exceeds maximum length of $maximum_pubkey_length."}); - } else { - my (undef, $keyfile) = tempfile('tmpXXXXXX', SUFFIX => '.gpg', TMPDIR => 1, OPEN => 0); - my $basename = catfile($plan_dir, $email); - IPC::Run::run ['gpg2', '--dearmor'], \$pubkey, '>', $keyfile, '2>>', '/dev/null' or die "gpg2 exited with $?"; - if(IPC::Run::run ['gpg2', '--no-default-keyring', '--keyring', "$keyfile", '--verify', "$basename.asc", "$basename.plan"], '>', '/dev/null', '2>>', '/dev/null') { - $plan->{'verified'} = 1; - print_json_response($cgi, 200, $plan); - } else { - print_json_response($cgi, 200, {verified => 0}); - } - } - } else { - print_response($cgi, 404, $not_found); + print_response($cgi, 301, {Location => $plan->{'redirect'}}); + return; } + if (!defined $plan) { + print_response($cgi, 404); + return; + } + my $pubkey = $cgi->http('X-Dotplan-Pubkey'); + if ((defined $pubkey && !defined $plan->{'signature'}) || + (defined $pubkey && !util_verify_plan($email, $pubkey))) { + print_response($cgi, 403); + return; + } + # check modified time + my $now = time; + my $mtime = $plan->{'mtime'}; + my $ifmod = $cgi->http('If-Modified-Since'); + my $ifmtime = HTTP::Date::str2time($ifmod) if defined $ifmod; + if (defined $mtime && defined $ifmtime && $ifmtime <= $now && $mtime <= $ifmtime) { + print_response($cgi, 304); + return; + } + # render response + my $body; + delete $plan->{'mtime'}; + my $format = $cgi->param('accept')->match(qw(text/plain application/json)); + if ($format eq 'application/json') { + $body = encode_json($plan); + } else { + $body = $plan->{'plan'}; + } + my $headers = { + 'Content-Type' => $format, + 'Last-Modified' => HTTP::Date::time2str($mtime) + }; + if (defined $pubkey) { + $headers->{'X-Dotplan-Verified'} = 'true'; + } + print_response($cgi, 200, $headers, $body); } ################### @@ -489,19 +511,6 @@ EOF IPC::Run::run \@arg, \$email or die "sendmail exited with $?"; } - # get mime type for response from querystring and accept header - sub util_get_response_format { - my $cgi = shift; - my $accept = $cgi->http('Accept'); - my $format = lc($cgi->param('format') || $cgi->http('Accept')); - if ($format eq 'json' || $format eq 'application/json') { - return 'application/json'; - } elsif ($format eq 'html' || $format eq 'text/html') { - return 'text/html'; - } - return 'text/plain'; - } - # encrypt a password with a provided or random salt sub util_bcrypt { my ($password, $salt) = @_; @@ -595,12 +604,12 @@ EOF } if (defined $plan && defined $signature) { - open(my $sig_file, '>', "$basename.asc") or die $!; + open(my $sig_file, '>', "$basename.sig") or die $!; flock($sig_file, LOCK_EX); print $sig_file $signature; close($sig_file); - } elsif (-f "$basename.asc") { - unlink "$basename.asc"; + } elsif (-f "$basename.sig") { + unlink "$basename.sig"; } # invalidate cache @@ -618,15 +627,15 @@ EOF open(my $plan_file, '<', "$basename.plan") or die $!; flock($plan_file, LOCK_SH); my $mtime = (stat($plan_file))[9]; - my $timestamp = HTTP::Date::time2str(localtime($mtime)); + my $timestamp = HTTP::Date::time2str($mtime); $details->{'mtime'} = $mtime; $details->{'timestamp'} = $timestamp; local $/; $details->{'plan'} = <$plan_file>; close($plan_file); - if (-f "$basename.asc") { - open(my $sig_file, '<', "$basename.asc") or die $!; + if (-f "$basename.sig") { + open(my $sig_file, '<', "$basename.sig") or die $!; flock($sig_file, LOCK_SH); local $/; $details->{'signature'} = <$sig_file>; @@ -670,11 +679,15 @@ EOF } } - # decode json post data to an object - sub util_json_body { - my $cgi = shift; - my $json = $cgi->param('POSTDATA') || $cgi->param('PUTDATA'); - return decode_json($json); + # verify a plan signature with a pubkey + sub util_verify_plan { + my ($email, $pubkey) = @_; + + my $basename = catfile($plan_dir, $email); + if(IPC::Run::run ['minisign', '-Vm', "$basename.plan", '-x', "$basename.sig", '-P', "$pubkey"], '>', '/dev/null', '2>>', '/dev/null') { + return 1; + } + return 0; } } diff --git a/test/run.sh b/test/run.sh index de0fd52..026743d 100755 --- a/test/run.sh +++ b/test/run.sh @@ -181,35 +181,27 @@ curl_test 'Get authentication token' 200 'application/json' -u $TEST_USER:test12 token=$(echo "$TEST_CONTENT" | jq -r '.token') -curl_test 'No plan by default' 404 '' localhost:$PORT/plan/$TEST_USER +curl_test 'No plan by default' 404 'application/json' localhost:$PORT/plan/$TEST_USER curl_test 'Reject bad authentication token' 401 'application/json' -XPUT -d '{"plan":"something","auth":"wrong"}' localhost:$PORT/plan/$TEST_USER curl_test 'Create a plan' 200 'application/json' -XPUT -d "{\"plan\":\"something\",\"auth\":\"$token\"}" localhost:$PORT/plan/$TEST_USER \ && assert_equal_jq '.success' 1 -curl_test 'Get initial plan' 200 'application/json' localhost:$PORT/plan/$TEST_USER?format=json \ +curl_test 'Get initial plan' 200 'application/json' -H 'Accept: application/json' localhost:$PORT/plan/$TEST_USER \ && assert_equal_jq '.plan' 'something' +curl_test 'Bad accept type' 406 'application/json' -H 'Accept: text/html' localhost:$PORT/plan/$TEST_USER + +curl_test 'Bad method' 405 'application/json' -XDELETE -H 'Accept: text/html' localhost:$PORT/plan/$TEST_USER + curl_test 'Create a plan' 200 'application/json' -XPUT -d "{\"plan\":\"some&thing\\nelse\",\"auth\":\"$token\"}" localhost:$PORT/plan/$TEST_USER \ && assert_equal_jq '.success' 1 -curl_test 'Get updated plan json using accept' 200 'application/json' -H 'Accept: application/json' localhost:$PORT/plan/$TEST_USER \ +curl_test 'Get updated plan json' 200 'application/json' -H 'Accept: application/json' localhost:$PORT/plan/$TEST_USER \ && assert_equal_jq '.plan' 'some&thing else' -curl_test 'Get updated plan json using querystring' 200 'application/json' localhost:$PORT/plan/$TEST_USER?format=json \ - && assert_equal_jq '.plan' 'some&thing -else' - -curl_test 'Get updated plan html using accept' 200 'text/html' -H 'Accept: text/html' localhost:$PORT/plan/$TEST_USER \ - && assert_equal 'html content' "$TEST_CONTENT" 'some&thing
-else' - -curl_test 'Get updated plan html using querystring' 200 'text/html' localhost:$PORT/plan/$TEST_USER?format=html \ - && assert_equal 'html content' "$TEST_CONTENT" 'some&thing
-else' - curl_test 'Get updated plan text' 200 'text/plain' localhost:$PORT/plan/$TEST_USER \ && assert_equal 'text content' "$TEST_CONTENT" 'some&thing else' @@ -217,20 +209,14 @@ else' curl_test 'Delete a plan' 200 'application/json' -XPUT -d "{\"auth\":\"$token\"}" localhost:$PORT/plan/$TEST_USER \ && assert_equal_jq '.success' 1 -curl_test 'Verify deleted plan' 404 '' localhost:$PORT/plan/$TEST_USER +curl_test 'Verify deleted plan' 404 'text/plain' -H 'Accept: text/*' localhost:$PORT/plan/$TEST_USER curl_test 'Create another plan for future tests' 200 'application/json' -XPUT -d "{\"plan\":\"for future tests\",\"auth\":\"$token\"}" localhost:$PORT/plan/$TEST_USER \ && assert_equal_jq '.success' 1 -curl_test 'Check missing plan in json using accept' 404 'application/json' -H 'Accept: application/json' localhost:$PORT/plan/testuser@exampl3.com +curl_test 'Check missing plan in json' 404 'application/json' -H 'Accept: application/json' localhost:$PORT/plan/testuser@exampl3.com -curl_test 'Check missing plan in json using querystring' 404 'application/json' localhost:$PORT/plan/testuser@exampl3.com?format=json - -curl_test 'Check missing plan in html using accept' 404 '' -H 'Accept: text/html' localhost:$PORT/plan/testuser@exampl3.com - -curl_test 'Check missing plan in html using querystring' 404 '' localhost:$PORT/plan/testuser@exampl3.com?format=html - -curl_test 'Check missing plan in text by omitting accept' 404 '' localhost:$PORT/plan/testuser@exampl3.com +curl_test 'Check missing plan in text' 404 'text/plain' -H 'Accept: text/*' localhost:$PORT/plan/testuser@exampl3.com curl_test 'Delete authentication token' 200 'application/json' -u $TEST_USER:test1234 -XDELETE localhost:$PORT/token @@ -269,14 +255,9 @@ curl_test 'Get signed plan' 200 'application/json' -H 'Accept: application/json' that is signed' \ && assert_notequal_jq '.signature' 'null' -post_data=$(<"$BASEDIR/signed-verify-bad.json") -curl_test 'Fail to verify with bad pubkey' 200 'application/json' -XPOST -d "$post_data" localhost:$PORT/verify/$TEST_USER \ - && assert_equal_jq '.verified' 0 \ - && assert_equal_jq '.plan' 'null' +curl_test 'Fail to verify with bad pubkey' 403 'text/plain' -H 'Accept: text/*' -H 'X-Dotplan-Pubkey: RWSM/86eVMfThd89U/aVHVpFrXhTO7x2PXGVJ2mu1o3YLxVNKy+IKYPK' localhost:$PORT/plan/$TEST_USER -post_data=$(<"$BASEDIR/signed-verify.json") -curl_test 'Verify signed plan' 200 'application/json' -XPOST -d "$post_data" localhost:$PORT/verify/$TEST_USER \ - && assert_equal_jq '.verified' 1 \ +curl_test 'Verify signed plan' 200 'application/json' -H 'Accept: application/json' -H 'X-Dotplan-Pubkey: RWTbCoXPuccYts4F50FuQh3G/yIXAzINpW6Vk/X1AEgwwf3K5nNLHA8W' localhost:$PORT/plan/$TEST_USER \ && assert_equal_jq '.plan' 'this is a plan that is signed' @@ -338,7 +319,7 @@ curl_test 'Create SQL injection plan' 200 'application/json' -XPUT -d "{\"plan\" && assert_exists 'benign plan file' 'data/plans' "$BADGUY_ESC.plan" now=`perl -e 'use HTTP::Date; print HTTP::Date::time2str(time)'` -curl_test 'If-Modified-Since header' 304 '' -H "If-Modified-Since: $now" localhost:$PORT/plan/$TEST_USER \ +curl_test 'If-Modified-Since header' 304 'text/plain' -H 'Accept: text/*' -H "If-Modified-Since: $now" localhost:$PORT/plan/$TEST_USER \ && assert_equal 'Empty content' "$TEST_CONTENT" "" ############### diff --git a/test/signed-create.json b/test/signed-create.json index 98788e6..8917d42 100644 --- a/test/signed-create.json +++ b/test/signed-create.json @@ -1 +1 @@ -{"plan":"this is a plan\nthat is signed\n","signature":"-----BEGIN PGP SIGNATURE-----\n\niQEzBAABCAAdFiEELxP8NJfva+suNmdu5BI4x54LZboFAl8TcMcACgkQ5BI4x54L\nZbofjgf/dwX1WmH9M8jLVddofR00QxFQUL9buOhkfOkk+yQ6ofIgONxoaF6rYPmd\nW5dRHqqcaVSZ4eowSpnUv+k1vNYW+qtuiiiBo+Yjqq+L0RINCUCQYtx1hNmhpruR\nmyUcfYz7BFMgbltmnnrmVtK4UzsiYpuQHWZIIprh6BmJ9QV747Km8iwlD3TwgEsh\nyMFVvToZuaYTXJhiQ5QzafpFFGoxfZ+v4FqVBs8ahXRvQojIB7Dvc6rl/dfjBMS6\nRWQCZ9+YZt0UsdIE5fEdsqDilXQZqT4rO3UJNy6crZGEvENy7UchQOgf/DIMTR2h\nZ7i5n0DPGi87YJSrgxyO4AxQ7OqzFg==\n=pUCT\n-----END PGP SIGNATURE-----","auth":"${TEST_EXPORTED_TOKEN}"} +{"plan":"this is a plan\nthat is signed\n","signature":"untrusted comment: signature from minisign secret key\nRWTbCoXPuccYtnB4TSMbc6CYXtDJiveKXFpE2/g5fif5/puEM7cJ87RKpY4J78KN5GD5bJWl4dPiWHNOddjjkro58rnnKF515w8=\ntrusted comment: timestamp:1595543722\tfile:plan.txt\niUmpPZrxxGu6oInSuQEndcJ7BKQwfpHQ7MlH1bj8ic3RTJPSfYS8+Q4fHivBxm20lQkCEY8FWW76u6V5Qa7rDg==","auth":"${TEST_EXPORTED_TOKEN}"} diff --git a/test/signed-verify-bad.json b/test/signed-verify-bad.json deleted file mode 100644 index 5754918..0000000 --- a/test/signed-verify-bad.json +++ /dev/null @@ -1 +0,0 @@ -{"pubkey":"-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQENBFxAsoUBCACo1b9nS1nUcMKohKMTeqItFeXGPdyfVOT5+mwgWfb3DRnIjUOd\nQ7ds2FAMAImEGorWRmqIDgp1tuUnkMk14vp6QK61dzvBgQhIzsAHDSzkrqc1PANp\nWaVAjrmZsAcHL54xGx46RzG7g9Xlbz4FdxhLZsUKycPaLR11/B9aA0LfFjpXKJpF\nQD0a+4PLOS4End/ANsVcSpTkmYP73e0WtwAjuT3v0OPG8OPEGgni/Vz0boH5M2Ab\nrQQJZuQvs7CCK+GRzedMwhySCVgfoWR01DmGcR5lg7Ib/XGPSTM9Sw7qWbFJenJM\n478kzm/mDkvNtZcS+FvGzv6LN7p/54M2qJ+DABEBAAG0I1J1ZGlzIE11aXpuaWVr\ncyA8cnVkaXNAc2l0b3Npcy5jb20+iQFOBBMBCAA4FiEEEgvnEVx6xhqlqyFXyr8v\nhu94hPkFAlxAsoUCGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQyr8vhu94\nhPn8hAf9HdpQ1MUMvOmEDWX9rBT/xPVQROVBvt5vfPdVgIgwB3ZbfRkGvNdFZFqg\nbGGszYyHDtaRd4LtZ83tejtHTTgz52P0OVWL2Fvx5yUY3/BZHmWMMgVENXw21NN0\nbNHPmO4HJzpXDCQFX4KYNntQJo1a7xuZTsnxQwN4JuQGubO5I4tNddVtD09f+3wA\nPDUGJblukoLuYVIk95DcpbUzT178KAShfevZjwPd3jhSFtqIvODmyQba2lSlcm0J\nTnTcwUJlFWujvNyP6Ejh1vCaGCJVxLJO+2ZrVPiQuVuzUs6S2fH4j+g5NvEMzJOT\nEcv4+A+g3lX9OxHcHkng+jigPVP8Q7kBDQRcQLKFAQgAwAEfbWUefsF2eTK1R3X7\nd9LuagIZq64ZZjOB6wp5zqzxjvyeckFMeNCHC4sqnCkLg02z3EHQKZ06OmerqZq+\n7N44b+hzr0QsmYS8syJEyEtrQ6K1ZGifWmLP48NuVnkAw1J8ptPBaSEcu/sAV1+J\nrxHzbm1lCAhW9BhPFAxyUo/s8EZvqSQM+lbIqSoMKFvTPMuSKq1T5Cn4QOYasZLs\nHIEK4OaTZATQaKNO2vGM0uyRt3fHgOkLRcRIJplOPGjkafrPxF9rvHO9oY+IZ7kU\n5Nwyhz0Q6Q6IwU5PdoHcnbhvvr4mXmEydsNSa18qfRcQdtCoOui+l0XfBnHwCwWR\njwARAQABiQE2BBgBCAAgFiEEEgvnEVx6xhqlqyFXyr8vhu94hPkFAlxAsoUCGwwA\nCgkQyr8vhu94hPm9cAf9GzauKsYP3PqwUYZyWM7ZkHBK7l5FhIJAmMDBmZdUZS3v\nrrPVLIguFAeS8udcgsUAQP56DbVKr7YgyZ9bd2H8rXVjpngUGl52r8pypzFG6/lV\nLx54t20e+IhSmYOS/Tqg8G49VXitV9A4qaRmDg1ZuO8RUs/ROkuv5Goa95dD2Dej\nj+hTjYq9cgwcA0L15EtJ+EIn/Bt+dzWesSWvdQVV3v9CXLzCqWu/uwzNuUFGB9I4\nQZWPu+OscxtYqMxKPCEzErnteIs5D4YKzMCjwvGMIt+SJ6ZV9Ns4hFI7LTPtzWnE\n5FRIZCq2o3JkIu/JyUGQrKl3e2f2toxATgmjw5xAwA==\n=LEyI\n-----END PGP PUBLIC KEY BLOCK-----"} diff --git a/test/signed-verify.json b/test/signed-verify.json deleted file mode 100644 index 836ae8c..0000000 --- a/test/signed-verify.json +++ /dev/null @@ -1 +0,0 @@ -{"pubkey":"-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQENBF8TaycBCADdvPY8mzLaMfSabcTyZVSO3cVZbojLMGfUCifGTB0K33AUSuRJ\nIQ/UPjonjGYkbqoQMC31XII0pPTt8WNeqHwaCaU83TMvIFz7c4HouB9prkdAGkSa\nn8yGq0Gr87/0eyWRkNvsoiJ8yU6kzYSjPEe3Tw8QFiTY2z4YvYBctmkAtI/NpYTd\n5oBizFhrsZtLcUxqjLqDaeQpcPxjEwqY4lIAiTS6aOhL8ziXxBYm2gx0DTEz261Z\nXNx0+SSYYi9RaiGwp96RXPMdAe4WBPm9i00GL86xJ+Gh5oCSd7zlz3qJovXcrZA7\njby/qcezaTVO9aoU0q+IqQUC4UQuiYurBgnrABEBAAG0KGRvdHBsYW4gb25saW5l\nIDxuby1yZXBseUBkb3RwbGFuLm9ubGluZT6JAVQEEwEIAD4WIQQvE/w0l+9r6y42\nZ27kEjjHngtlugUCXxNrJwIbAwUJA8JnAAULCQgHAgYVCgkICwIEFgIDAQIeAQIX\ngAAKCRDkEjjHngtlujcNCADdbqUDZ/sFPDIK6V0oDdRYGAfFCiFtAm0hiQ3p6UC9\ngRhvxnWh4X38o5Z3ghBTpd4GOVsdD0C7WA3UvS/Or11Or6GPwM5O077RaL04cvNG\n2aB3QOSNTqoElyuaUfRx6B6HYcvMTpgCcHvYyDCPuwt1msEWYJLWR4o0SwuCekzR\nDZjuu9fEt+1b4Xfa38x2LPET4Udz8875kftmPWPkQkY9msWDqTknxcav+RSYMvlG\nCARNV/B17sNbCnNVVstF7vVDRabKC8NEgWgpPyd8qya5lniN8TkfMMICU1OxqrsB\n/GBWV4fchyU1rmheDzAlBG3Cc2DfBlRMBtvIfoUvlVyOuQENBF8TaycBCADKEzdy\nZrtwNobQqNhLQuIovpAWAZ0fcVE1ZLEHEAjcnZM4lP+psmhXrcc1BABaW5z54fNU\nnDYovFgQA8YSklNd6XuIah+EVHl9PkMUCjcWV6Izs5ExjuGXdI5XisNWpY1le+n2\nKnMwVKta+UsQ1+omEfDR0AKpAZT3G4xIEx/C4LQIBc1XCAkV1Fflha6x2JFJt6jZ\ncwoWmbLtHK8maWdv09sNkTU4+vxLGiQkJbeYuUWlh2cHysMO1OAoc8zjlK+xFK09\nmm3OayMNg1LgRRrJ8AEwftfDtz2UKIq1YdFGcPGbp8IGJHPSM0VIhGewg6qZluXl\nLXcrq+vHfDEtlrhlABEBAAGJATwEGAEIACYWIQQvE/w0l+9r6y42Z27kEjjHngtl\nugUCXxNrJwIbDAUJA8JnAAAKCRDkEjjHngtlurzoB/sGZxpT9QA0tbDETAtExU9F\nYR6Yqho19FUdMlmKk7pGEJMnCFdcLoDn7o0F1OD8UvpFXVofzd6bHqCXyvQS22Pf\n2BrKHL+4UqN5ETppy4NzV3bXifOJGAbR3JFX8P2zS1opvsa69te/dSiTdwjcOd4E\nhwAiL5H1lyiEczL0w+Pyes7qfAye7IEOGcgXtmlQL/7SAzmBZLcADt/6UPYRT8b4\nCfGqL2mXhwMp9euXmTA/I47Y6tf8PNQupqHWvYjTH8OOXazsLUqgLDKzjH7t5mdD\nFecCDS4CtC6xPm1rGQyikS3UCKR96RTiacP7BTqnAQUk9ZYO38/lDSwf8hSOsM2R\n=neJG\n-----END PGP PUBLIC KEY BLOCK-----"}