Diff -- /var/www/vhosts/3dshawn.com/site1/MODS/Crypto.pm

O Operator
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
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
11package MODS::Crypto;
22#======================================================================
33# DriftSense -- MODS::Crypto
44#
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.
87#
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).
1410#
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:
1614#
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
2132#
2233# 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.
2535#======================================================================
2636use strict;
2737use warnings;
28use IPC::Open3;
29use Symbol 'gensym';
30use POSIX ();
38use Digest::SHA qw(sha256 hmac_sha256);
39use MIME::Base64 qw(encode_base64 decode_base64);
3140
32my $OPENSSL_BIN = '/usr/bin/openssl';
3341my $MASTER_KEY_PATH_DFT = '/etc/drift_sense/master.key';
34my $CIPHER = 'aes-256-cbc';
35my $KDF_ITERS = 100_000;
42my $SALT_LEN = 16;
43my $TAG_LEN = 32;
44my $BLOCK = 32; # SHA-256 output size in bytes
3645
3746#---------------------------------------------------------------------
3847sub is_ready {
3948 my ($path) = @_;
40 return 0 unless -x $OPENSSL_BIN;
4149 return -r ($path || $MASTER_KEY_PATH_DFT) ? 1 : 0;
4250}
4351
4452#---------------------------------------------------------------------
4553sub ensure_master {
4654 my ($path) = @_;
4755 $path ||= $MASTER_KEY_PATH_DFT;
4856 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";
5160 close $u;
5261 open(my $out, '>', $path) or die "MODS::Crypto: cannot write $path: $!\n";
62 binmode $out;
5363 print $out unpack('H*', $bytes);
5464 close $out;
5565 chmod 0600, $path;
5666 return 1;
5767}
5868
5969#---------------------------------------------------------------------
6070sub encrypt {
6171 my ($plain, %opts) = @_;
62 return undef unless defined $plain && length $plain;
72 return undef unless defined $plain;
6373 my $master = _load_master($opts{master_key_path});
6474 die "MODS::Crypto: master key not available\n" unless defined $master;
6575
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;
7185}
7286
7387#---------------------------------------------------------------------
7488sub 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;
7791 my $master = _load_master($opts{master_key_path});
7892 die "MODS::Crypto: master key not available\n" unless defined $master;
7993
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);
85108}
86109
87110#---------------------------------------------------------------------
88111sub _load_master {
89112 my ($override_path) = @_;
90113 my $path = $override_path || $MASTER_KEY_PATH_DFT;
91114 return undef unless -r $path;
92115 open(my $fh, '<', $path) or return undef;
93116 local $/; my $m = <$fh> // '';
94117 close $fh;
95118 $m =~ s/\s+$//;
96119 return length $m ? $m : undef;
120}
121
122sub _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;
97129}
98130
99131#---------------------------------------------------------------------
100# _run(stdin_data, secret, argv) -> stdout on success, undef on fail
132# _stream_xor($data, $key_enc)
101133#
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).
105137#---------------------------------------------------------------------
106sub _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;
138sub _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++;
118147 }
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;
133150}
134151
135sub _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#---------------------------------------------------------------------
155sub _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;
146163}
147sub _unlink_temp { my $p = shift; unlink $p if $p && $p =~ m!^/tmp/\.ds_key_!; }
148164
1491651;