Restore

O Operator
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
SiteDriftSense self-monitor on local
Kindlocal
Captured at2026-07-11 23:15:08
Captured SHAc0bc42cd7bdfe5798760f900130c0a57d0930627821e10b9cef50c631873d711
Current state on disk
Live check just now. If SHAs match, the restore is a no-op.
File presentyes
Size5769 bytes
Current SHA7feb67ef8834
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.
11package MODS::Crypto;
22#======================================================================
33# 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:
144#
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.
238#
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).
2614#
2715# 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
3221#
3322# 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).
3525#======================================================================
3626use strict;
3727use warnings;
38use Digest::SHA qw(sha256 hmac_sha256);
39use MIME::Base64 qw(encode_base64 decode_base64);
28use IPC::Open3;
29use Symbol 'gensym';
30use POSIX ();
4031
32my $OPENSSL_BIN = '/usr/bin/openssl';
4133my $MASTER_KEY_PATH_DFT = '/etc/drift_sense/master.key';
42my $SALT_LEN = 16;
43my $TAG_LEN = 32;
44my $BLOCK = 32; # SHA-256 output size in bytes
34my $CIPHER = 'aes-256-cbc';
35my $KDF_ITERS = 100_000;
4536
4637#---------------------------------------------------------------------
4738sub is_ready {
4839 my ($path) = @_;
40 return 0 unless -x $OPENSSL_BIN;
4941 return -r ($path || $MASTER_KEY_PATH_DFT) ? 1 : 0;
5042}
5143
5244#---------------------------------------------------------------------
5345sub ensure_master {
5446 my ($path) = @_;
5547 $path ||= $MASTER_KEY_PATH_DFT;
5648 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);
6051 close $u;
6152 open(my $out, '>', $path) or die "MODS::Crypto: cannot write $path: $!\n";
62 binmode $out;
6353 print $out unpack('H*', $bytes);
6454 close $out;
6555 chmod 0600, $path;
6656 return 1;
6757}
6858
6959#---------------------------------------------------------------------
7060sub encrypt {
7161 my ($plain, %opts) = @_;
72 return undef unless defined $plain;
62 return undef unless defined $plain && length $plain;
7363 my $master = _load_master($opts{master_key_path});
7464 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);
8265
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);
8571}
8672
8773#---------------------------------------------------------------------
8874sub 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;
9177 my $master = _load_master($opts{master_key_path});
9278 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);
10679
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);
10885}
10986
11087#---------------------------------------------------------------------
11188sub _load_master {
11289 my ($override_path) = @_;
11390 my $path = $override_path || $MASTER_KEY_PATH_DFT;
11491 return undef unless -r $path;
11592 open(my $fh, '<', $path) or return undef;
11693 local $/; my $m = <$fh> // '';
11794 close $fh;
11895 $m =~ s/\s+$//;
11996 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;
12997}
13098
13199#---------------------------------------------------------------------
132# _stream_xor($data, $key_enc)
100# _run(stdin_data, secret, argv) -> stdout on success, undef on fail
133101#
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.
137105#---------------------------------------------------------------------
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++;
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;
147118 }
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;
150133}
151134
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;
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;
163146}
147sub _unlink_temp { my $p = shift; unlink $p if $p && $p =~ m!^/tmp/\.ds_key_!; }
164148
1651491;
Confirm restore
Backs current content to <target>.drift_restore_backup_<epoch>, writes the captured content, verifies SHA post-write.
Cancel Logged to RESTORE_LOG regardless of outcome.