Diff -- /var/www/vhosts/3dshawn.com/site1/MODS/Crypto.pm
Diff
/var/www/vhosts/3dshawn.com/site1/MODS/Crypto.pm
modified on local at 2026-07-11 23:17:53
Added
+95
lines
Removed
-79
lines
Context
70
unchanged
Blobs
from c0bc42cd7bdf
to 7feb67ef8834
to 7feb67ef8834
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.| 1 | 1 | package MODS::Crypto; |
| 2 | 2 | #====================================================================== |
| 3 | 3 | # DriftSense -- MODS::Crypto |
| 4 | 4 | # |
| 5 | # Symmetric encryption of secrets (SSH private keys, webhook auth | |
| 6 | # tokens, etc.) so they can be stored in the DriftSense DB without | |
| 7 | # leaving plaintext at rest. | |
| 5 | # Symmetric encryption of secrets (SSH private keys etc.) so they can | |
| 6 | # be stored in the DriftSense DB without leaving plaintext at rest. | |
| 8 | 7 | # |
| 9 | # Design: shells out to /usr/bin/openssl. No CPAN modules required. | |
| 10 | # AES-256-CBC + PBKDF2 (100k iterations), random per-payload salt, base64 | |
| 11 | # output. Master secret is passed via `-pass file:PATH` where PATH is | |
| 12 | # a mode-0600 temp file that exists only for the duration of the call | |
| 13 | # (safe from `ps` and env-var snooping). | |
| 8 | # Design: pure Perl. No CPAN modules, no shell-out. | |
| 9 | # Uses Digest::SHA (core since 5.9.3) + MIME::Base64 (core forever). | |
| 14 | 10 | # |
| 15 | # Public API: | |
| 11 | # Cipher: HMAC-SHA-256 in CTR mode as the stream cipher, plus a second | |
| 12 | # HMAC-SHA-256 over (salt || ciphertext) as the authenticator. This is | |
| 13 | # an encrypt-then-MAC construction: | |
| 16 | 14 | # |
| 17 | # my $ciphertext_b64 = MODS::Crypto::encrypt($plaintext); | |
| 18 | # my $plaintext = MODS::Crypto::decrypt($ciphertext_b64); | |
| 19 | # MODS::Crypto::is_ready(); # 1 if master key + openssl OK | |
| 20 | # MODS::Crypto::ensure_master(); # generate master if missing | |
| 15 | # salt = 16 random bytes from /dev/urandom | |
| 16 | # key_enc = HMAC-SHA256(master, "enc" || salt) | |
| 17 | # key_mac = HMAC-SHA256(master, "mac" || salt) | |
| 18 | # for each 32-byte block i of plaintext: | |
| 19 | # keystream_i = HMAC-SHA256(key_enc, pack('N', i)) | |
| 20 | # ct_i = plaintext_i XOR keystream_i | |
| 21 | # tag = HMAC-SHA256(key_mac, salt || ciphertext) | |
| 22 | # output = base64(salt || ciphertext || tag) | |
| 23 | # | |
| 24 | # Decrypt reverses this and verifies the tag before returning plaintext | |
| 25 | # (constant-time tag compare protects against oracle attacks). | |
| 26 | # | |
| 27 | # Public API: | |
| 28 | # $enc = MODS::Crypto::encrypt($plaintext); # returns base64 string | |
| 29 | # $pt = MODS::Crypto::decrypt($encoded); # undef on tag mismatch | |
| 30 | # MODS::Crypto::is_ready(); # 1 if master key present | |
| 31 | # MODS::Crypto::ensure_master(); # generate master if missing | |
| 21 | 32 | # |
| 22 | 33 | # Master secret lives at /etc/drift_sense/master.key (root:root, mode 600). |
| 23 | # If missing, encrypt/decrypt refuse to run (fail-safe -- never silently | |
| 24 | # store secrets under a default key). | |
| 34 | # If missing, encrypt/decrypt refuse to run. | |
| 25 | 35 | #====================================================================== |
| 26 | 36 | use strict; |
| 27 | 37 | use warnings; |
| 28 | use IPC::Open3; | |
| 29 | use Symbol 'gensym'; | |
| 30 | use POSIX (); | |
| 38 | use Digest::SHA qw(sha256 hmac_sha256); | |
| 39 | use MIME::Base64 qw(encode_base64 decode_base64); | |
| 31 | 40 | |
| 32 | my $OPENSSL_BIN = '/usr/bin/openssl'; | |
| 33 | 41 | my $MASTER_KEY_PATH_DFT = '/etc/drift_sense/master.key'; |
| 34 | my $CIPHER = 'aes-256-cbc'; | |
| 35 | my $KDF_ITERS = 100_000; | |
| 42 | my $SALT_LEN = 16; | |
| 43 | my $TAG_LEN = 32; | |
| 44 | my $BLOCK = 32; # SHA-256 output size in bytes | |
| 36 | 45 | |
| 37 | 46 | #--------------------------------------------------------------------- |
| 38 | 47 | sub is_ready { |
| 39 | 48 | my ($path) = @_; |
| 40 | return 0 unless -x $OPENSSL_BIN; | |
| 41 | 49 | return -r ($path || $MASTER_KEY_PATH_DFT) ? 1 : 0; |
| 42 | 50 | } |
| 43 | 51 | |
| 44 | 52 | #--------------------------------------------------------------------- |
| 45 | 53 | sub ensure_master { |
| 46 | 54 | my ($path) = @_; |
| 47 | 55 | $path ||= $MASTER_KEY_PATH_DFT; |
| 48 | 56 | return 0 if -f $path; |
| 49 | open(my $u, '<:raw', '/dev/urandom') or die "MODS::Crypto: no /dev/urandom: $!\n"; | |
| 50 | read($u, my $bytes, 32); | |
| 57 | open(my $u, '<:raw', '/dev/urandom') | |
| 58 | or die "MODS::Crypto: no /dev/urandom: $!\n"; | |
| 59 | read($u, my $bytes, 32) or die "MODS::Crypto: /dev/urandom read: $!\n"; | |
| 51 | 60 | close $u; |
| 52 | 61 | open(my $out, '>', $path) or die "MODS::Crypto: cannot write $path: $!\n"; |
| 62 | binmode $out; | |
| 53 | 63 | print $out unpack('H*', $bytes); |
| 54 | 64 | close $out; |
| 55 | 65 | chmod 0600, $path; |
| 56 | 66 | return 1; |
| 57 | 67 | } |
| 58 | 68 | |
| 59 | 69 | #--------------------------------------------------------------------- |
| 60 | 70 | sub encrypt { |
| 61 | 71 | my ($plain, %opts) = @_; |
| 62 | return undef unless defined $plain && length $plain; | |
| 72 | return undef unless defined $plain; | |
| 63 | 73 | my $master = _load_master($opts{master_key_path}); |
| 64 | 74 | die "MODS::Crypto: master key not available\n" unless defined $master; |
| 65 | 75 | |
| 66 | my @argv = ($OPENSSL_BIN, 'enc', | |
| 67 | '-' . $CIPHER, | |
| 68 | '-salt', '-pbkdf2', '-iter', $KDF_ITERS, | |
| 69 | '-base64', '-A'); | |
| 70 | return _run($plain, $master, \@argv); | |
| 76 | my $salt = _random_bytes($SALT_LEN); | |
| 77 | my $key_enc = hmac_sha256("enc" . $salt, $master); | |
| 78 | my $key_mac = hmac_sha256("mac" . $salt, $master); | |
| 79 | ||
| 80 | my $ct = _stream_xor($plain, $key_enc); | |
| 81 | my $tag = hmac_sha256($salt . $ct, $key_mac); | |
| 82 | ||
| 83 | my $b64 = encode_base64($salt . $ct . $tag, ''); | |
| 84 | return $b64; | |
| 71 | 85 | } |
| 72 | 86 | |
| 73 | 87 | #--------------------------------------------------------------------- |
| 74 | 88 | sub decrypt { |
| 75 | my ($cipher, %opts) = @_; | |
| 76 | return undef unless defined $cipher && length $cipher; | |
| 89 | my ($encoded, %opts) = @_; | |
| 90 | return undef unless defined $encoded && length $encoded; | |
| 77 | 91 | my $master = _load_master($opts{master_key_path}); |
| 78 | 92 | die "MODS::Crypto: master key not available\n" unless defined $master; |
| 79 | 93 | |
| 80 | my @argv = ($OPENSSL_BIN, 'enc', '-d', | |
| 81 | '-' . $CIPHER, | |
| 82 | '-salt', '-pbkdf2', '-iter', $KDF_ITERS, | |
| 83 | '-base64', '-A'); | |
| 84 | return _run($cipher, $master, \@argv); | |
| 94 | my $raw = decode_base64($encoded); | |
| 95 | return undef unless defined $raw && length($raw) >= $SALT_LEN + $TAG_LEN; | |
| 96 | ||
| 97 | my $salt = substr($raw, 0, $SALT_LEN); | |
| 98 | my $tag = substr($raw, length($raw) - $TAG_LEN); | |
| 99 | my $ct = substr($raw, $SALT_LEN, length($raw) - $SALT_LEN - $TAG_LEN); | |
| 100 | ||
| 101 | my $key_enc = hmac_sha256("enc" . $salt, $master); | |
| 102 | my $key_mac = hmac_sha256("mac" . $salt, $master); | |
| 103 | ||
| 104 | my $expect_tag = hmac_sha256($salt . $ct, $key_mac); | |
| 105 | return undef unless _ct_eq($tag, $expect_tag); | |
| 106 | ||
| 107 | return _stream_xor($ct, $key_enc); | |
| 85 | 108 | } |
| 86 | 109 | |
| 87 | 110 | #--------------------------------------------------------------------- |
| 88 | 111 | sub _load_master { |
| 89 | 112 | my ($override_path) = @_; |
| 90 | 113 | my $path = $override_path || $MASTER_KEY_PATH_DFT; |
| 91 | 114 | return undef unless -r $path; |
| 92 | 115 | open(my $fh, '<', $path) or return undef; |
| 93 | 116 | local $/; my $m = <$fh> // ''; |
| 94 | 117 | close $fh; |
| 95 | 118 | $m =~ s/\s+$//; |
| 96 | 119 | return length $m ? $m : undef; |
| 120 | } | |
| 121 | ||
| 122 | sub _random_bytes { | |
| 123 | my ($n) = @_; | |
| 124 | open(my $u, '<:raw', '/dev/urandom') or die "no /dev/urandom: $!\n"; | |
| 125 | my $bytes; | |
| 126 | read($u, $bytes, $n) or die "urandom short read: $!\n"; | |
| 127 | close $u; | |
| 128 | return $bytes; | |
| 97 | 129 | } |
| 98 | 130 | |
| 99 | 131 | #--------------------------------------------------------------------- |
| 100 | # _run(stdin_data, secret, argv) -> stdout on success, undef on fail | |
| 132 | # _stream_xor($data, $key_enc) | |
| 101 | 133 | # |
| 102 | # Writes the master secret to a mode-0600 temp file, passes -pass file:PATH | |
| 103 | # to openssl (safe from ps + env snooping), pipes stdin_data via stdin, | |
| 104 | # captures stdout, cleans up. | |
| 134 | # CTR-style keystream: for each 32-byte block i, | |
| 135 | # keystream_i = HMAC-SHA256($key_enc, pack('N', i)) | |
| 136 | # XOR with $data, truncate to length($data). | |
| 105 | 137 | #--------------------------------------------------------------------- |
| 106 | sub _run { | |
| 107 | my ($stdin_data, $secret, $argv) = @_; | |
| 108 | ||
| 109 | my $key_path = _write_temp_secret($secret); | |
| 110 | my @full_argv = (@$argv, '-pass', "file:$key_path"); | |
| 111 | ||
| 112 | my ($wtr, $rdr, $err_fh); | |
| 113 | $err_fh = gensym; | |
| 114 | my $pid = eval { open3($wtr, $rdr, $err_fh, @full_argv) }; | |
| 115 | if ($@ || !$pid) { | |
| 116 | _unlink_temp($key_path); | |
| 117 | return undef; | |
| 138 | sub _stream_xor { | |
| 139 | my ($data, $key_enc) = @_; | |
| 140 | my $len = length $data; | |
| 141 | my $out = ''; | |
| 142 | my $i = 0; | |
| 143 | while (length($out) < $len) { | |
| 144 | my $keystream = hmac_sha256(pack('N', $i), $key_enc); | |
| 145 | $out .= $keystream; | |
| 146 | $i++; | |
| 118 | 147 | } |
| 119 | binmode $wtr; | |
| 120 | print {$wtr} $stdin_data; | |
| 121 | close $wtr; | |
| 122 | ||
| 123 | my $out = do { local $/; <$rdr> // '' }; | |
| 124 | my $err = do { local $/; <$err_fh> // '' }; | |
| 125 | close $rdr; close $err_fh; | |
| 126 | waitpid $pid, 0; | |
| 127 | my $rc = $? >> 8; | |
| 128 | _unlink_temp($key_path); | |
| 129 | ||
| 130 | return undef if $rc != 0; | |
| 131 | chomp $out; | |
| 132 | return $out; | |
| 148 | $out = substr($out, 0, $len); | |
| 149 | return $data ^ $out; | |
| 133 | 150 | } |
| 134 | 151 | |
| 135 | sub _write_temp_secret { | |
| 136 | my ($secret) = @_; | |
| 137 | my $t = POSIX::strftime('%s', localtime); | |
| 138 | my $path = "/tmp/.ds_key_${t}_$$_" . int(rand(1_000_000)); | |
| 139 | # Create with tight perms atomically -- O_CREAT|O_EXCL|O_WRONLY, mode 600 | |
| 140 | require Fcntl; | |
| 141 | sysopen(my $fh, $path, Fcntl::O_WRONLY() | Fcntl::O_CREAT() | Fcntl::O_EXCL(), 0600) | |
| 142 | or die "MODS::Crypto: temp $path: $!\n"; | |
| 143 | print $fh $secret; | |
| 144 | close $fh; | |
| 145 | return $path; | |
| 152 | #--------------------------------------------------------------------- | |
| 153 | # Constant-time string comparison (byte-wise XOR + OR accumulator) | |
| 154 | #--------------------------------------------------------------------- | |
| 155 | sub _ct_eq { | |
| 156 | my ($a, $b) = @_; | |
| 157 | return 0 if length($a) != length($b); | |
| 158 | my $diff = 0; | |
| 159 | for my $i (0 .. length($a) - 1) { | |
| 160 | $diff |= ord(substr($a, $i, 1)) ^ ord(substr($b, $i, 1)); | |
| 161 | } | |
| 162 | return $diff == 0 ? 1 : 0; | |
| 146 | 163 | } |
| 147 | sub _unlink_temp { my $p = shift; unlink $p if $p && $p =~ m!^/tmp/\.ds_key_!; } | |
| 148 | 164 | |
| 149 | 165 | 1; |