Restore
Restore
Restore file to captured state
Every captured change carries its SHA-256-addressed content in BLOB_STORE. Restoring writes that content back to the original path — current file gets backed up to <path>.drift_restore_backup_<epoch> before overwrite.
What will change
Preview -- nothing has been written yet.
| Target file | /var/www/vhosts/3dshawn.com/site1/MODS/Crypto.pm |
| Site | DriftSense self-monitor on local |
| Kind | local |
| Captured at | 2026-07-11 23:15:08 |
| Captured SHA | c0bc42cd7bdfe5798760f900130c0a57d0930627821e10b9cef50c631873d711 |
Current state on disk
Live check just now. If SHAs match, the restore is a no-op.
| File present | yes |
| Size | 5769 bytes |
| Current SHA | 7feb67ef8834 |
| Same as captured? | no -- restore will change it |
What restore will change — live diff
Left column shows current on-disk content; right shows what restore will write. +79 additions, -95 deletions, 70 unchanged context lines.
| 1 | 1 | package MODS::Crypto; |
| 2 | 2 | #====================================================================== |
| 3 | 3 | # DriftSense -- MODS::Crypto |
| 4 | # | |
| 5 | # Symmetric encryption of secrets (SSH private keys etc.) so they can | |
| 6 | # be stored in the DriftSense DB without leaving plaintext at rest. | |
| 7 | # | |
| 8 | # Design: pure Perl. No CPAN modules, no shell-out. | |
| 9 | # Uses Digest::SHA (core since 5.9.3) + MIME::Base64 (core forever). | |
| 10 | # | |
| 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: | |
| 14 | 4 | # |
| 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) | |
| 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. | |
| 23 | 8 | # |
| 24 | # Decrypt reverses this and verifies the tag before returning plaintext | |
| 25 | # (constant-time tag compare protects against oracle attacks). | |
| 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). | |
| 26 | 14 | # |
| 27 | 15 | # 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 | |
| 16 | # | |
| 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 | |
| 32 | 21 | # |
| 33 | 22 | # Master secret lives at /etc/drift_sense/master.key (root:root, mode 600). |
| 34 | # If missing, encrypt/decrypt refuse to run. | |
| 23 | # If missing, encrypt/decrypt refuse to run (fail-safe -- never silently | |
| 24 | # store secrets under a default key). | |
| 35 | 25 | #====================================================================== |
| 36 | 26 | use strict; |
| 37 | 27 | use warnings; |
| 38 | use Digest::SHA qw(sha256 hmac_sha256); | |
| 39 | use MIME::Base64 qw(encode_base64 decode_base64); | |
| 28 | use IPC::Open3; | |
| 29 | use Symbol 'gensym'; | |
| 30 | use POSIX (); | |
| 40 | 31 | |
| 32 | my $OPENSSL_BIN = '/usr/bin/openssl'; | |
| 41 | 33 | my $MASTER_KEY_PATH_DFT = '/etc/drift_sense/master.key'; |
| 42 | my $SALT_LEN = 16; | |
| 43 | my $TAG_LEN = 32; | |
| 44 | my $BLOCK = 32; # SHA-256 output size in bytes | |
| 34 | my $CIPHER = 'aes-256-cbc'; | |
| 35 | my $KDF_ITERS = 100_000; | |
| 45 | 36 | |
| 46 | 37 | #--------------------------------------------------------------------- |
| 47 | 38 | sub is_ready { |
| 48 | 39 | my ($path) = @_; |
| 40 | return 0 unless -x $OPENSSL_BIN; | |
| 49 | 41 | return -r ($path || $MASTER_KEY_PATH_DFT) ? 1 : 0; |
| 50 | 42 | } |
| 51 | 43 | |
| 52 | 44 | #--------------------------------------------------------------------- |
| 53 | 45 | sub ensure_master { |
| 54 | 46 | my ($path) = @_; |
| 55 | 47 | $path ||= $MASTER_KEY_PATH_DFT; |
| 56 | 48 | return 0 if -f $path; |
| 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"; | |
| 49 | open(my $u, '<:raw', '/dev/urandom') or die "MODS::Crypto: no /dev/urandom: $!\n"; | |
| 50 | read($u, my $bytes, 32); | |
| 60 | 51 | close $u; |
| 61 | 52 | open(my $out, '>', $path) or die "MODS::Crypto: cannot write $path: $!\n"; |
| 62 | binmode $out; | |
| 63 | 53 | print $out unpack('H*', $bytes); |
| 64 | 54 | close $out; |
| 65 | 55 | chmod 0600, $path; |
| 66 | 56 | return 1; |
| 67 | 57 | } |
| 68 | 58 | |
| 69 | 59 | #--------------------------------------------------------------------- |
| 70 | 60 | sub encrypt { |
| 71 | 61 | my ($plain, %opts) = @_; |
| 72 | return undef unless defined $plain; | |
| 62 | return undef unless defined $plain && length $plain; | |
| 73 | 63 | my $master = _load_master($opts{master_key_path}); |
| 74 | 64 | die "MODS::Crypto: master key not available\n" unless defined $master; |
| 75 | ||
| 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 | 65 | |
| 83 | my $b64 = encode_base64($salt . $ct . $tag, ''); | |
| 84 | return $b64; | |
| 66 | my @argv = ($OPENSSL_BIN, 'enc', | |
| 67 | '-' . $CIPHER, | |
| 68 | '-salt', '-pbkdf2', '-iter', $KDF_ITERS, | |
| 69 | '-base64', '-A'); | |
| 70 | return _run($plain, $master, \@argv); | |
| 85 | 71 | } |
| 86 | 72 | |
| 87 | 73 | #--------------------------------------------------------------------- |
| 88 | 74 | sub decrypt { |
| 89 | my ($encoded, %opts) = @_; | |
| 90 | return undef unless defined $encoded && length $encoded; | |
| 75 | my ($cipher, %opts) = @_; | |
| 76 | return undef unless defined $cipher && length $cipher; | |
| 91 | 77 | my $master = _load_master($opts{master_key_path}); |
| 92 | 78 | die "MODS::Crypto: master key not available\n" unless defined $master; |
| 93 | ||
| 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 | 79 | |
| 107 | return _stream_xor($ct, $key_enc); | |
| 80 | my @argv = ($OPENSSL_BIN, 'enc', '-d', | |
| 81 | '-' . $CIPHER, | |
| 82 | '-salt', '-pbkdf2', '-iter', $KDF_ITERS, | |
| 83 | '-base64', '-A'); | |
| 84 | return _run($cipher, $master, \@argv); | |
| 108 | 85 | } |
| 109 | 86 | |
| 110 | 87 | #--------------------------------------------------------------------- |
| 111 | 88 | sub _load_master { |
| 112 | 89 | my ($override_path) = @_; |
| 113 | 90 | my $path = $override_path || $MASTER_KEY_PATH_DFT; |
| 114 | 91 | return undef unless -r $path; |
| 115 | 92 | open(my $fh, '<', $path) or return undef; |
| 116 | 93 | local $/; my $m = <$fh> // ''; |
| 117 | 94 | close $fh; |
| 118 | 95 | $m =~ s/\s+$//; |
| 119 | 96 | 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; | |
| 129 | 97 | } |
| 130 | 98 | |
| 131 | 99 | #--------------------------------------------------------------------- |
| 132 | # _stream_xor($data, $key_enc) | |
| 100 | # _run(stdin_data, secret, argv) -> stdout on success, undef on fail | |
| 133 | 101 | # |
| 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). | |
| 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. | |
| 137 | 105 | #--------------------------------------------------------------------- |
| 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++; | |
| 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; | |
| 147 | 118 | } |
| 148 | $out = substr($out, 0, $len); | |
| 149 | return $data ^ $out; | |
| 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; | |
| 150 | 133 | } |
| 151 | 134 | |
| 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; | |
| 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; | |
| 163 | 146 | } |
| 147 | sub _unlink_temp { my $p = shift; unlink $p if $p && $p =~ m!^/tmp/\.ds_key_!; } | |
| 164 | 148 | |
| 165 | 149 | 1; |
Confirm restore
Backs current content to <target>.drift_restore_backup_<epoch>, writes the captured content, verifies SHA post-write.