switching from pgp to minisign for signatures and verification
This commit is contained in:
parent
aef00ab24d
commit
86c8b4ba73
18
Dockerfile
18
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
|
||||
|
|
34
README.md
34
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
|
||||
|
||||
|
|
405
server.pl
405
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);
|
||||
}
|
||||
} 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);
|
||||
|
||||
# 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, 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);
|
||||
print_response($cgi, 406);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
print_response($cgi, 405, $not_allowed);
|
||||
print_response($cgi, 405);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 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,20 +245,25 @@ 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 {
|
||||
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.'});
|
||||
} elsif (defined $user && defined $user->{'pw_token_expires'} && $user->{'pw_token_expires'} >= time) {
|
||||
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."});
|
||||
} else {
|
||||
my $password = util_json_body($cgi)->{'password'};
|
||||
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."});
|
||||
} else {
|
||||
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'), ?)";
|
||||
|
@ -238,38 +278,38 @@ EOF
|
|||
"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 {
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
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
|
||||
sub get_token {
|
||||
my $cgi = shift;
|
||||
my $user = util_get_authenticated($cgi);
|
||||
if (!defined $user) {
|
||||
print_response($cgi, 401, $not_authorized);
|
||||
} else {
|
||||
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');
|
||||
|
@ -284,31 +324,33 @@ EOF
|
|||
die $sth->errstr if $sth->err;
|
||||
print_json_response($cgi, 200, {token => $token});
|
||||
}
|
||||
}
|
||||
|
||||
##### DELETE /token
|
||||
sub delete_token {
|
||||
my $cgi = shift;
|
||||
my $user = util_get_authenticated($cgi);
|
||||
if (!defined $user) {
|
||||
print_response($cgi, 401, $not_authorized);
|
||||
} else {
|
||||
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 {
|
||||
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);
|
||||
|
@ -320,128 +362,108 @@ EOF
|
|||
"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);
|
||||
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.'});
|
||||
} elsif (!defined $password || length($password) < $minimum_password_length) {
|
||||
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."});
|
||||
} else {
|
||||
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);
|
||||
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, $not_authorized);
|
||||
} elsif (defined $plan && length($plan) > $maximum_plan_length) {
|
||||
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."});
|
||||
} elsif (defined $signature && length($signature) > $maximum_signature_length) {
|
||||
return;
|
||||
}
|
||||
if (defined $signature && length($signature) > $maximum_signature_length) {
|
||||
print_json_response($cgi, 400, {error => "Signature exceeds maximum length of $maximum_signature_length."});
|
||||
} else {
|
||||
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
|
||||
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, '', $format, undef, $mtime);
|
||||
} else {
|
||||
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);
|
||||
} 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
##### 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});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
print_response($cgi, 404, $not_found);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
45
test/run.sh
45
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<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&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' 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" ""
|
||||
|
||||
###############
|
||||
|
|
|
@ -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-----"}
|
Reference in New Issue