package SellkeysAuth;
# sellkeys Auth – Perl SDK (Perl 5.14+). Needs CryptX (cpanm CryptX); HTTP::Tiny, JSON::PP and Digest::SHA are core.
# For https also IO::Socket::SSL (cpanm IO::Socket::SSL).
#
#   use SellkeysAuth;
#   my $auth = SellkeysAuth->new(app_id => "APP_ID", public_key => "PUBLIC_KEY", version => "1.0");
#   eval {
#       $auth->init;
#       my $lic = $auth->license("XXXXX-XXXXX-XXXXX-XXXXX");
#       print "Welcome! Level $lic->{level}\n";
#       1;
#   } or do {
#       my $e = $@;
#       die $e unless ref $e && $e->isa("SellkeysAuth::Error");
#       print "Login failed: ", $e->code, " ", $e->message, "\n";
#   };
#
# Every answer is signed with your app's Ed25519 key; the SDK rejects wrong signatures and replayed nonces, so a fake
# server cannot unlock your program. (It does not protect against patching your program.)
use strict;
use warnings;
use Carp ();
use Crypt::PK::Ed25519;
use Crypt::PRNG ();
use Digest::SHA ();
use HTTP::Tiny;
use JSON::PP ();
use MIME::Base64 ();
use Sys::Hostname ();

our $API_URL = "https://auth.sellkeys.cc/v1";

{
    package SellkeysAuth::Error;
    use overload '""' => sub { $_[0]{code} . ": " . $_[0]{message} }, fallback => 1;
    sub new { my ($c, $code, $message, $data) = @_; bless { code => $code, message => $message, data => $data || {} }, $c }
    sub code { $_[0]{code} }
    sub message { $_[0]{message} }
    # full server answer, e.g. ->data->{update_url} for code "update_required"
    sub data { $_[0]{data} }
}

sub _fail { die SellkeysAuth::Error->new(@_) }

# Stable per-machine id (hashed, the raw value never leaves the machine).
sub default_hwid {
    my $raw = "";
    if ($^O eq "MSWin32" || $^O eq "cygwin" || $^O eq "msys") {
        my $out = `reg query "HKLM\\SOFTWARE\\Microsoft\\Cryptography" /v MachineGuid /reg:64 2>nul` // "";
        $raw = $1 if $out =~ /MachineGuid\s+REG_SZ\s+(\S+)/;
    } elsif ($^O eq "darwin") {
        my $out = `ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null` // "";
        $raw = $1 if $out =~ /"IOPlatformUUID" = "([^"]+)"/;
    } else {
        for my $p ("/etc/machine-id", "/var/lib/dbus/machine-id") {
            if (open my $fh, "<", $p) { chomp($raw = <$fh> // ""); close $fh; last if $raw ne "" }
        }
    }
    $raw = Sys::Hostname::hostname() if $raw eq "";
    return Digest::SHA::sha256_hex($raw);
}

# SHA-256 of your program file (for the app's hash check). Defaults to the running script.
sub file_hash {
    my ($path) = @_;
    return Digest::SHA->new(256)->addfile($path // $0, "b")->hexdigest;
}

sub new {
    my ($class, %o) = @_;
    my $raw = MIME::Base64::decode_base64($o{public_key} // "");
    _fail("bad_public_key", "The public key must be 32 bytes, base64.") unless length($raw) == 32;
    my $pk = eval { Crypt::PK::Ed25519->new->import_key_raw($raw, "public") }
        or _fail("bad_public_key", "The public key must be 32 bytes, base64.");
    return bless {
        app_id  => $o{app_id},
        version => $o{version},
        api_url => $o{api_url} // $API_URL,
        hwid    => $o{hwid} // default_hwid(),
        timeout => $o{timeout} // 15,
        session => undef, app => undef, license_info => undef, user => undef,
        _pk     => $pk,
    }, $class;
}

sub session { $_[0]{session} }
sub hwid { $_[0]{hwid} }

sub _call {
    my ($self, $type, %fields) = @_;
    my $nonce = unpack "H*", Crypt::PRNG::random_bytes(16);
    my %form = (type => $type, app => $self->{app_id}, nonce => $nonce);
    defined $fields{$_} and $form{$_} = $fields{$_} for keys %fields;
    my $r = HTTP::Tiny->new(timeout => $self->{timeout})->post_form($self->{api_url}, \%form);
    _fail("network_error", "Could not reach the license server: $r->{content}") if $r->{status} == 599;
    my $raw = $r->{content} // "";
    my $sig = $r->{headers}{"x-sellkeys-signature"};
    unless (defined $sig) { # only errors before the app is known (rate limit, unknown app) are unsigned
        my $b = eval { JSON::PP::decode_json($raw) } || {};
        _fail($b->{code} // "http_$r->{status}", $b->{message} // "Unexpected server response.");
    }
    my $ok = eval { $self->{_pk}->verify_message(MIME::Base64::decode_base64($sig), $raw) };
    _fail("bad_signature", "The server response is not signed by sellkeys.") unless $ok;
    my $body = JSON::PP::decode_json($raw);
    _fail("bad_nonce", "The server response does not belong to this request.") unless ($body->{nonce} // "") eq $nonce;
    _fail($body->{code} // "error", $body->{message} // "Request failed.", $body) unless $body->{success};
    return $body;
}

sub _trim { my $s = shift; $s =~ s/^\s+|\s+$//g; $s }
sub _ensure_session { $_[0]->init unless defined $_[0]{session} }

# Starts a session. Dies with code "update_required" (see ->data->{update_url}) if your version is too old.
sub init {
    my ($self, $hash) = @_;
    my $b = $self->_call("init", version => $self->{version}, hash => $hash);
    $self->{session} = $b->{session};
    return $self->{app} = $b->{app};
}

sub license {
    my ($self, $key) = @_;
    $self->_ensure_session;
    return $self->{license_info} = $self->_call("license", session => $self->{session}, key => _trim($key), hwid => $self->{hwid})->{license};
}

# Call periodically: dies if the license/account was banned, expired or the session ended.
sub check {
    my ($self) = @_;
    my $b = $self->_call("check", session => $self->{session});
    return $self->{user} = $b->{user} if exists $b->{user};
    return $self->{license_info} = $b->{license};
}

# ---- accounts (username + password; the key is redeemed to the account) ----
sub register {
    my ($self, $username, $password, $key) = @_;
    $self->_ensure_session;
    return $self->{user} = $self->_call("register", session => $self->{session}, username => $username, password => $password, key => _trim($key), hwid => $self->{hwid})->{user};
}

sub login {
    my ($self, $username, $password) = @_;
    $self->_ensure_session;
    return $self->{user} = $self->_call("login", session => $self->{session}, username => $username, password => $password, hwid => $self->{hwid})->{user};
}

# Adds another license (renewal, higher level) to an existing account.
sub upgrade {
    my ($self, $username, $key) = @_;
    $self->_ensure_session;
    return $self->_call("upgrade", session => $self->{session}, username => $username, key => _trim($key))->{user};
}

# ---- content ----
# Global app variable (set in the dashboard).
sub var { $_[0]->_call("var", session => $_[0]{session}, name => $_[1])->{value} }
sub get_user_var { $_[0]->_call("getvar", session => $_[0]{session}, name => $_[1])->{value} }
sub set_user_var { $_[0]->_call("setvar", session => $_[0]{session}, name => $_[1], value => $_[2]); return }

# Downloads an app file and checks it against the signed SHA-256. Returns the bytes.
sub download {
    my ($self, $file_id) = @_;
    my $info = $self->_call("file", session => $self->{session}, file => $file_id);
    my $timeout = $self->{timeout} > 120 ? $self->{timeout} : 120;
    my $r = HTTP::Tiny->new(timeout => $timeout)->get($info->{url});
    _fail("network_error", "Download failed: HTTP $r->{status} $r->{reason}") unless $r->{success};
    _fail("bad_file_hash", "The downloaded file does not match its signed checksum.") unless Digest::SHA::sha256_hex($r->{content}) eq $info->{sha256};
    return $r->{content};
}

# Calls a webhook set in the dashboard through sellkeys (its URL and keys stay on the server). Returns ($status, $response).
sub webhook {
    my ($self, $name, $params, $body, $content_type) = @_;
    my $b = $self->_call("webhook", session => $self->{session}, webid => $name, params => (defined $params && $params ne "" ? $params : undef), body => $body, content_type => $content_type);
    return ($b->{status}, $b->{response});
}

# Writes a line to the app's logs in the sellkeys dashboard.
sub log { $_[0]->_call("log", session => $_[0]{session}, message => substr($_[1], 0, 500)); return }

sub logout {
    my ($self) = @_;
    return unless defined $self->{session};
    $self->_call("logout", session => $self->{session});
    $self->{session} = undef;
    return;
}

1;
