switching from pgp to minisign for signatures and verification

main
Rudis Muiznieks 2 years ago
parent aef00ab24d
commit 86c8b4ba73
  1. 18
      Dockerfile
  2. 34
      README.md
  3. 551
      server.pl
  4. 45
      test/run.sh
  5. 2
      test/signed-create.json
  6. 1
      test/signed-verify-bad.json
  7. 1
      test/signed-verify.json

@ -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

@ -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

@ -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', {});
}
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);
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']}
}
} 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);
},
{
path => qr/^\/token$/,
methods => {
GET => {handler => \&get_token, valid_types => ['application/json']},
DELETE => {handler => \&delete_token, valid_types => ['application/json']}
}
} 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);
},
{
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']}
}
} 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);
},
{
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']}
}
} elsif ($method eq 'DELETE') {
if ($path =~ /^\/token$/) {
delete_token($cgi);
} else {
print_response($cgi, 404, $not_found);
},
];
eval {
util_log("REQ $req_id $method $path");
# 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;
}
}
} 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 <<EOF;
HTTP/1.1 $code $header
HTTP/1.1 $code $code_description
Server: DotplanApi
Date: $date$mtime_header$content_header$redirect_header
Date: $date$extra_headers$length_header
EOF
print "\n$body";
}
sub print_json_response {
my ($cgi, $code, $data) = @_;
print_response($cgi, $code, encode_json($data));
my ($cgi, $code, $data, $headers) = @_;
if (!defined $headers) {
$headers = {};
};
$headers->{'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;
}
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});
}
$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_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."});
} 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});
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 ($cgi, $email) = @_;
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/<br>\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');
}
print_response($cgi, 301, {Location => $plan->{'redirect'}});
return;
}
}
##### POST /verify/{email}
sub verify_plan {
my ($email, $cgi) = @_;
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});
}
}
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 {
print_response($cgi, 404, $not_found);
$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;
}
}

@ -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 \
&& 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 \
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 html using accept' 200 'text/html' -H 'Accept: text/html' localhost:$PORT/plan/$TEST_USER \
&& assert_equal 'html content' "$TEST_CONTENT" 'some&amp;thing<br>
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&amp;thing<br>
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 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 json' 404 'application/json' -H 'Accept: application/json' localhost:$PORT/plan/testuser@exampl3.com
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" ""
###############

@ -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}"}

@ -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-----"}

@ -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-----"}
Loading…
Cancel
Save