number_station 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.asciidoc +81 -3
- data/Rakefile +58 -4
- data/lib/number_station/GLaDOS_espeak.rb +69 -26
- data/lib/number_station/cli.rb +1441 -138
- data/lib/number_station/config_reader.rb +91 -14
- data/lib/number_station/decrypt_message.rb +75 -31
- data/lib/number_station/encrypt_message.rb +112 -41
- data/lib/number_station/examine_pads.rb +171 -0
- data/lib/number_station/make_onetime_pad.rb +58 -23
- data/lib/number_station/phonetic_conversion.rb +38 -49
- data/lib/number_station/version.rb +1 -1
- data/lib/number_station.rb +36 -6
- data/number_station.gemspec +3 -3
- data/resources/conf.yaml +415 -0
- data/resources/repeat_message.txt +1 -0
- metadata +20 -18
- data/resources/conf.json +0 -14
|
@@ -19,25 +19,102 @@
|
|
|
19
19
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
20
20
|
=end
|
|
21
21
|
|
|
22
|
+
require 'yaml'
|
|
23
|
+
require 'fileutils'
|
|
24
|
+
require 'date'
|
|
25
|
+
|
|
22
26
|
module NumberStation
|
|
23
27
|
class ConfigReader
|
|
28
|
+
def self.read_config
|
|
29
|
+
config_path = user_config_path || default_config_path
|
|
30
|
+
load_config(config_path)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.user_config_path
|
|
34
|
+
yaml_path = File.join(Dir.home, "number_station", "conf.yaml")
|
|
35
|
+
json_path = File.join(Dir.home, "number_station", "conf.json")
|
|
36
|
+
|
|
37
|
+
# Prefer YAML, but support JSON for backward compatibility
|
|
38
|
+
return yaml_path if File.exist?(yaml_path)
|
|
39
|
+
return json_path if File.exist?(json_path)
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.default_config_path
|
|
44
|
+
yaml_path = File.join(File.dirname(__FILE__), "../../resources/conf.yaml")
|
|
45
|
+
json_path = File.join(File.dirname(__FILE__), "../../resources/conf.json")
|
|
46
|
+
|
|
47
|
+
# Prefer YAML, but support JSON for backward compatibility
|
|
48
|
+
return yaml_path if File.exist?(yaml_path)
|
|
49
|
+
return json_path if File.exist?(json_path)
|
|
50
|
+
yaml_path # Default to YAML path
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.load_config(config_path)
|
|
54
|
+
config_data = if config_path.end_with?('.yaml') || config_path.end_with?('.yml')
|
|
55
|
+
# Use safe_load with permitted classes to handle Date objects
|
|
56
|
+
YAML.safe_load(File.read(config_path), permitted_classes: [Date, Time], aliases: true)
|
|
57
|
+
else
|
|
58
|
+
require 'json'
|
|
59
|
+
JSON.parse(File.read(config_path))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
NumberStation.set_data(config_data)
|
|
63
|
+
setup_logger(config_data["logging"]["level"])
|
|
64
|
+
NumberStation.log.debug "Reading in config file: #{config_path}"
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
# Ensure logger exists before trying to log
|
|
67
|
+
unless NumberStation.log
|
|
68
|
+
setup_logger(Logger::WARN)
|
|
69
|
+
end
|
|
70
|
+
NumberStation.log.error "Failed to load config: #{e.message}"
|
|
71
|
+
raise
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.setup_logger(level)
|
|
75
|
+
NumberStation.set_log(Logger.new(STDOUT))
|
|
76
|
+
NumberStation.log.level = level
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.save_config(config_data = nil)
|
|
80
|
+
config_data ||= NumberStation.data
|
|
81
|
+
config_path = user_config_path || File.join(Dir.home, "number_station", "conf.yaml")
|
|
82
|
+
|
|
83
|
+
# Ensure directory exists
|
|
84
|
+
FileUtils.mkdir_p(File.dirname(config_path))
|
|
85
|
+
|
|
86
|
+
# Convert Date/Time objects to strings before saving
|
|
87
|
+
sanitized_data = sanitize_for_yaml(config_data.dup)
|
|
88
|
+
|
|
89
|
+
# Save as YAML
|
|
90
|
+
File.write(config_path, sanitized_data.to_yaml)
|
|
91
|
+
NumberStation.set_data(config_data) # Update in-memory data (keep original format)
|
|
92
|
+
NumberStation.log.debug "Saved config file: #{config_path}"
|
|
93
|
+
config_path
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
# Ensure logger exists before trying to log
|
|
96
|
+
unless NumberStation.log
|
|
97
|
+
setup_logger(Logger::WARN)
|
|
98
|
+
end
|
|
99
|
+
NumberStation.log.error "Failed to save config: #{e.message}"
|
|
100
|
+
raise
|
|
101
|
+
end
|
|
24
102
|
|
|
25
|
-
def self.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
NumberStation.log.debug "Reading in default config file: #{config_file_path}"
|
|
103
|
+
def self.sanitize_for_yaml(data)
|
|
104
|
+
case data
|
|
105
|
+
when Hash
|
|
106
|
+
data.each_with_object({}) do |(key, value), result|
|
|
107
|
+
result[key] = sanitize_for_yaml(value)
|
|
108
|
+
end
|
|
109
|
+
when Array
|
|
110
|
+
data.map { |item| sanitize_for_yaml(item) }
|
|
111
|
+
when Date, Time
|
|
112
|
+
data.to_s
|
|
113
|
+
else
|
|
114
|
+
data
|
|
38
115
|
end
|
|
39
|
-
NumberStation.log.debug "NumberStation::ConfigReader#read_config"
|
|
40
116
|
end
|
|
41
117
|
|
|
118
|
+
private
|
|
42
119
|
end
|
|
43
120
|
end
|
|
@@ -18,47 +18,91 @@
|
|
|
18
18
|
You should have received a copy of the GNU General Public License
|
|
19
19
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
20
20
|
=end
|
|
21
|
-
require "securerandom"
|
|
22
21
|
require "json"
|
|
23
22
|
|
|
24
23
|
module NumberStation
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
def self.decrypt_message(message, pad_path, pad_num, message_file_path = nil)
|
|
25
|
+
# Strip whitespace and newlines to handle formatted input (groups of 5)
|
|
26
|
+
cleaned_message = message.gsub(/[\s\n\r]/, '')
|
|
27
|
+
NumberStation.log.debug "original message length: #{message.size}, cleaned length: #{cleaned_message.size}"
|
|
28
|
+
|
|
29
|
+
pad_data = load_pad_data(pad_path)
|
|
30
|
+
pad_key = pad_data["pads"][pad_num]["key"]
|
|
31
|
+
|
|
32
|
+
validate_message_length(cleaned_message.size, pad_key.size)
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
end
|
|
34
|
+
message_bytes = hex_string_to_bytes(cleaned_message)
|
|
35
|
+
pad_bytes = hex_string_to_bytes(pad_key)
|
|
36
|
+
decrypted_bytes = xor_decrypt(message_bytes, pad_bytes)
|
|
37
|
+
decrypted_string = bytes_to_string(decrypted_bytes)
|
|
35
38
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
NumberStation.log.debug "message length less than pad length: #{message.size <= crypto_hex_str.size}"
|
|
39
|
+
# Extract encrypted filename if message_file_path is provided
|
|
40
|
+
encrypted_filename = message_file_path ? File.basename(message_file_path) : nil
|
|
41
|
+
write_decrypted_file(pad_data["id"], pad_num, decrypted_string, encrypted_filename)
|
|
42
|
+
decrypted_string
|
|
43
|
+
end
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
private
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
def self.load_pad_data(pad_path)
|
|
48
|
+
JSON.parse(File.read(pad_path))
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
NumberStation.log.error "Failed to load pad file: #{e.message}"
|
|
51
|
+
raise
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.validate_message_length(message_size, pad_size)
|
|
55
|
+
if message_size > pad_size
|
|
56
|
+
NumberStation.log.error "Message length (#{message_size}) is greater than pad length (#{pad_size}). Unable to continue decryption."
|
|
57
|
+
raise ArgumentError, "Message too long for pad"
|
|
48
58
|
end
|
|
59
|
+
NumberStation.log.debug "message length less than pad length: #{message_size <= pad_size}"
|
|
60
|
+
end
|
|
49
61
|
|
|
50
|
-
|
|
62
|
+
def self.hex_string_to_bytes(hex_string)
|
|
63
|
+
hex_string.scan(/.{2}/).map { |pair| pair.to_i(16) }
|
|
64
|
+
end
|
|
51
65
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
rescue Exception => e
|
|
59
|
-
raise e
|
|
60
|
-
end
|
|
61
|
-
return decrypted_string
|
|
66
|
+
def self.xor_decrypt(message_bytes, pad_bytes)
|
|
67
|
+
message_bytes.map.with_index { |byte, index| byte ^ pad_bytes[index] }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.bytes_to_string(bytes)
|
|
71
|
+
bytes.pack('U*').force_encoding('utf-8')
|
|
62
72
|
end
|
|
63
73
|
|
|
74
|
+
def self.write_decrypted_file(pad_id, pad_num, decrypted_content, encrypted_filename = nil)
|
|
75
|
+
# Only write to file if decrypting from a file (not from string)
|
|
76
|
+
unless encrypted_filename
|
|
77
|
+
NumberStation.log.debug "Decrypted message (not saving to file)"
|
|
78
|
+
return
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Try to extract agent name and pad info from encrypted filename if provided
|
|
82
|
+
if encrypted_filename && !encrypted_filename.empty?
|
|
83
|
+
# Parse encrypted filename format: agentname_name-of-the-pad-file_padnumber_encrypted.txt
|
|
84
|
+
# Example: Abyss_Abyss-2026-01-12-001_pad2_encrypted.txt
|
|
85
|
+
if encrypted_filename.match(/^(.+?)_(.+?)_pad(\d+)_encrypted\.txt$/)
|
|
86
|
+
agent_name = $1
|
|
87
|
+
pad_filename = $2
|
|
88
|
+
pad_num_from_filename = $3
|
|
89
|
+
|
|
90
|
+
# Use the same format but with _decrypted.txt
|
|
91
|
+
filename = "#{agent_name}_#{pad_filename}_pad#{pad_num_from_filename}_decrypted.txt"
|
|
92
|
+
NumberStation.log.info "Writing decrypted message to file #{filename}"
|
|
93
|
+
File.write(filename, decrypted_content)
|
|
94
|
+
return
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Fallback: extract agent name from pad_path if available
|
|
99
|
+
# For now, use pad_id and pad_num as fallback
|
|
100
|
+
filename = "#{pad_id}_pad#{pad_num}_decrypted.txt"
|
|
101
|
+
|
|
102
|
+
NumberStation.log.info "Writing decrypted message to file #{filename}"
|
|
103
|
+
File.write(filename, decrypted_content)
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
NumberStation.log.error "Failed to write decrypted file: #{e.message}"
|
|
106
|
+
raise
|
|
107
|
+
end
|
|
64
108
|
end
|
|
@@ -18,62 +18,133 @@
|
|
|
18
18
|
You should have received a copy of the GNU General Public License
|
|
19
19
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
20
20
|
=end
|
|
21
|
-
require "securerandom"
|
|
22
21
|
require "json"
|
|
23
22
|
|
|
24
23
|
module NumberStation
|
|
25
|
-
|
|
26
|
-
def self.encrypt_message(message, pad_path, pad_num)
|
|
24
|
+
def self.encrypt_message(message, pad_path, pad_num, message_file_path = nil)
|
|
27
25
|
NumberStation.log.debug "message length: #{message.size}"
|
|
28
|
-
|
|
26
|
+
|
|
27
|
+
pad_data = load_pad_data(pad_path)
|
|
28
|
+
pad_key = pad_data["pads"][pad_num]["key"]
|
|
29
|
+
|
|
30
|
+
validate_message_length(message.size, pad_key.size)
|
|
31
|
+
mark_pad_as_consumed(pad_data, pad_path, pad_num)
|
|
29
32
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
message_bytes = message.unpack('U*')
|
|
34
|
+
pad_bytes = hex_string_to_bytes(pad_key)
|
|
35
|
+
encrypted_bytes = xor_encrypt(message_bytes, pad_bytes)
|
|
36
|
+
encrypted_hex = bytes_to_hex_string(encrypted_bytes)
|
|
37
|
+
formatted_hex = format_hex_in_groups(encrypted_hex, 5)
|
|
38
|
+
|
|
39
|
+
write_encrypted_file(pad_path, pad_num, formatted_hex, message_file_path)
|
|
40
|
+
formatted_hex
|
|
41
|
+
end
|
|
35
42
|
|
|
36
|
-
|
|
43
|
+
private
|
|
37
44
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
45
|
+
def self.load_pad_data(pad_path)
|
|
46
|
+
JSON.parse(File.read(pad_path))
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
NumberStation.log.error "Failed to load pad file: #{e.message}"
|
|
49
|
+
raise
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.validate_message_length(message_size, pad_size)
|
|
53
|
+
if message_size > pad_size
|
|
54
|
+
NumberStation.log.error "Message length (#{message_size}) is larger than pad length (#{pad_size}). Break the message into smaller parts."
|
|
55
|
+
raise ArgumentError, "Message too long for pad"
|
|
41
56
|
end
|
|
42
57
|
NumberStation.log.debug "message length less than pad length"
|
|
58
|
+
end
|
|
43
59
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
msg = "Warning pad #{pad_num} has been consumed on #{Time.at(pad_data["pads"][pad_num]["epoch_date"])}"
|
|
53
|
-
NumberStation.log.error msg
|
|
54
|
-
exit
|
|
60
|
+
def self.mark_pad_as_consumed(pad_data, pad_path, pad_num)
|
|
61
|
+
pad = pad_data["pads"][pad_num]
|
|
62
|
+
|
|
63
|
+
if pad["consumed"]
|
|
64
|
+
consumed_date = Time.at(pad["epoch_date"])
|
|
65
|
+
error_msg = "Pad #{pad_num} has already been consumed on #{consumed_date}"
|
|
66
|
+
NumberStation.log.error error_msg
|
|
67
|
+
raise ArgumentError, error_msg
|
|
55
68
|
end
|
|
56
69
|
|
|
57
|
-
|
|
70
|
+
NumberStation.log.debug "Marking key as consumed"
|
|
71
|
+
pad["epoch_date"] = Time.now.to_i
|
|
72
|
+
pad["consumed"] = true
|
|
73
|
+
File.write(pad_path, pad_data.to_json)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.hex_string_to_bytes(hex_string)
|
|
77
|
+
hex_string.scan(/.{2}/).map { |pair| pair.to_i(16) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.xor_encrypt(message_bytes, pad_bytes)
|
|
81
|
+
message_bytes.map.with_index { |byte, index| byte ^ pad_bytes[index] }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.bytes_to_hex_string(bytes)
|
|
85
|
+
bytes.map { |byte| '%02x' % (byte & 0xFF) }.join
|
|
86
|
+
end
|
|
58
87
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
88
|
+
def self.format_hex_in_groups(hex_string, group_size)
|
|
89
|
+
hex_string.scan(/.{1,#{group_size}}/).join(' ')
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.write_encrypted_file(pad_path, pad_num, encrypted_content, message_file_path = nil)
|
|
93
|
+
# Extract agent name from pad path if it's in an agent subdirectory
|
|
94
|
+
# Path format: ~/number_station/pads/AGENTNAME/padfile.json
|
|
95
|
+
agent_name = extract_agent_name_from_path(pad_path)
|
|
96
|
+
|
|
97
|
+
# Extract pad filename without extension
|
|
98
|
+
pad_basename = File.basename(pad_path, ".json")
|
|
99
|
+
|
|
100
|
+
# Build filename: agentname_name-of-the-pad-file_padnumber_encrypted.txt
|
|
101
|
+
if agent_name && !agent_name.empty?
|
|
102
|
+
filename = "#{agent_name}_#{pad_basename}_pad#{pad_num}_encrypted.txt"
|
|
103
|
+
else
|
|
104
|
+
# Fallback if no agent name found
|
|
105
|
+
filename = "#{pad_basename}_pad#{pad_num}_encrypted.txt"
|
|
62
106
|
end
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
NumberStation.log.info "Writing encrypted message to file #{
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
rescue Exception => e
|
|
73
|
-
raise e
|
|
107
|
+
|
|
108
|
+
# Write to file if:
|
|
109
|
+
# 1. Encrypting from a file (message_file_path is actual file path), OR
|
|
110
|
+
# 2. Encrypting from string but agent was specified (message_file_path is "string_input")
|
|
111
|
+
if message_file_path
|
|
112
|
+
NumberStation.log.info "Writing encrypted message to file #{filename}"
|
|
113
|
+
File.write(filename, encrypted_content)
|
|
114
|
+
else
|
|
115
|
+
NumberStation.log.debug "Encrypted message (not saving to file): #{filename}"
|
|
74
116
|
end
|
|
75
|
-
|
|
76
|
-
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
NumberStation.log.error "Failed to write encrypted file: #{e.message}"
|
|
119
|
+
raise
|
|
77
120
|
end
|
|
78
121
|
|
|
122
|
+
def self.extract_agent_name_from_path(pad_path)
|
|
123
|
+
# Extract agent name from path like: ~/number_station/pads/AGENTNAME/padfile.json
|
|
124
|
+
# or: /path/to/pads/AGENTNAME/padfile.json
|
|
125
|
+
normalized_path = File.expand_path(pad_path)
|
|
126
|
+
pads_dir = File.expand_path(File.join(Dir.home, "number_station", "pads"))
|
|
127
|
+
|
|
128
|
+
# Check if pad is in a subdirectory of pads directory
|
|
129
|
+
if normalized_path.start_with?(pads_dir + File::SEPARATOR)
|
|
130
|
+
relative_path = normalized_path.sub(pads_dir + File::SEPARATOR, "")
|
|
131
|
+
path_parts = relative_path.split(File::SEPARATOR)
|
|
132
|
+
|
|
133
|
+
# If path has multiple parts, first part is likely agent name
|
|
134
|
+
if path_parts.length > 1
|
|
135
|
+
agent_name = path_parts.first
|
|
136
|
+
# Verify it's not just the pad filename (should be a directory)
|
|
137
|
+
return agent_name if File.directory?(File.join(pads_dir, agent_name))
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Try to extract from filename if it starts with agent name pattern
|
|
142
|
+
# Format: agentname-YYYY-MM-DD.json
|
|
143
|
+
pad_basename = File.basename(pad_path, ".json")
|
|
144
|
+
if pad_basename.match?(/^([\w-]+)-\d{4}-\d{2}-\d{2}(-\d{3})?$/)
|
|
145
|
+
return $1
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
79
150
|
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
=begin
|
|
2
|
+
Ruby Number Station
|
|
3
|
+
Author: David Kirwan https://gitub.com/davidkirwan
|
|
4
|
+
Licence: GPL 3.0
|
|
5
|
+
NumberStation is a collection of utilities to aid in the running of a number station
|
|
6
|
+
Copyright (C) 2018 David Kirwan
|
|
7
|
+
|
|
8
|
+
This program is free software: you can redistribute it and/or modify
|
|
9
|
+
it under the terms of the GNU General Public License as published by
|
|
10
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
11
|
+
(at your option) any later version.
|
|
12
|
+
|
|
13
|
+
This program is distributed in the hope that it will be useful,
|
|
14
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
15
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
16
|
+
GNU General Public License for more details.
|
|
17
|
+
|
|
18
|
+
You should have received a copy of the GNU General Public License
|
|
19
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
20
|
+
=end
|
|
21
|
+
|
|
22
|
+
require "json"
|
|
23
|
+
|
|
24
|
+
module NumberStation
|
|
25
|
+
def self.examine_pads(pads_directory = nil)
|
|
26
|
+
pads_dir = pads_directory || File.join(Dir.home, "number_station", "pads")
|
|
27
|
+
|
|
28
|
+
unless Dir.exist?(pads_dir)
|
|
29
|
+
NumberStation.log.warn "Pads directory does not exist: #{pads_dir}"
|
|
30
|
+
return []
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Look for pad files in various formats:
|
|
34
|
+
# - Old format: one_time_pad_XXXXX.json (random number)
|
|
35
|
+
# - New format: agentname-YYYY-MM-DD.json or one_time_pad-YYYY-MM-DD.json (date-based)
|
|
36
|
+
# - New format with counter: agentname-YYYY-MM-DD-001.json
|
|
37
|
+
pad_files = Dir.glob(File.join(pads_dir, "*.json")).select do |file|
|
|
38
|
+
basename = File.basename(file)
|
|
39
|
+
# Match old format: one_time_pad_XXXXX.json
|
|
40
|
+
# Match new format: agentname-YYYY-MM-DD.json or one_time_pad-YYYY-MM-DD.json
|
|
41
|
+
# Match new format with counter: agentname-YYYY-MM-DD-001.json
|
|
42
|
+
basename.match?(/^(one_time_pad|[\w-]+)[_-]\d{4}-\d{2}-\d{2}(-\d{3})?\.json$/) ||
|
|
43
|
+
basename.match?(/^one_time_pad_\d+\.json$/) ||
|
|
44
|
+
basename.match?(/^[\w-]+_\d+\.json$/)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
if pad_files.empty?
|
|
48
|
+
NumberStation.log.info "No pad files found in #{pads_dir}"
|
|
49
|
+
return []
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
pad_files.map { |file| examine_pad_file(file) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.find_next_available_pad(pads_directory = nil, min_length = nil, require_unconsumed = true, agent_name = nil)
|
|
56
|
+
# Determine pads directory based on agent name
|
|
57
|
+
if agent_name && !agent_name.empty?
|
|
58
|
+
pads_dir = File.join(Dir.home, "number_station", "pads", agent_name)
|
|
59
|
+
else
|
|
60
|
+
pads_dir = pads_directory || File.join(Dir.home, "number_station", "pads")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
unless Dir.exist?(pads_dir)
|
|
64
|
+
error_msg = agent_name ? "Agent pad directory does not exist: #{pads_dir}" : "Pads directory does not exist: #{pads_dir}"
|
|
65
|
+
raise ArgumentError, error_msg
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Look for pad files in various formats:
|
|
69
|
+
# - Old format: one_time_pad_XXXXX.json (random number)
|
|
70
|
+
# - New format: agentname-YYYY-MM-DD.json or one_time_pad-YYYY-MM-DD.json (date-based)
|
|
71
|
+
# - New format with counter: agentname-YYYY-MM-DD-001.json
|
|
72
|
+
pad_files = Dir.glob(File.join(pads_dir, "*.json")).select do |file|
|
|
73
|
+
basename = File.basename(file)
|
|
74
|
+
# Match old format: one_time_pad_XXXXX.json
|
|
75
|
+
# Match new format: agentname-YYYY-MM-DD.json or one_time_pad-YYYY-MM-DD.json
|
|
76
|
+
# Match new format with counter: agentname-YYYY-MM-DD-001.json
|
|
77
|
+
basename.match?(/^(one_time_pad|[\w-]+)[_-]\d{4}-\d{2}-\d{2}(-\d{3})?\.json$/) ||
|
|
78
|
+
basename.match?(/^one_time_pad_\d+\.json$/) ||
|
|
79
|
+
basename.match?(/^[\w-]+_\d+\.json$/)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
if pad_files.empty?
|
|
83
|
+
error_msg = agent_name ? "No pad files found for agent '#{agent_name}' in #{pads_dir}" : "No pad files found in #{pads_dir}"
|
|
84
|
+
raise ArgumentError, error_msg
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Sort pads to find the oldest one
|
|
88
|
+
# For date-based filenames, alphabetical sort works (YYYY-MM-DD format)
|
|
89
|
+
# For old format (random numbers), sort alphabetically still works
|
|
90
|
+
pad_files.sort!
|
|
91
|
+
|
|
92
|
+
# Find the oldest pad file that has at least one unconsumed pad
|
|
93
|
+
pad_files.each do |file_path|
|
|
94
|
+
pad_data = JSON.parse(File.read(file_path))
|
|
95
|
+
pads_hash = pad_data["pads"]
|
|
96
|
+
|
|
97
|
+
next if pads_hash.nil? || pads_hash.empty?
|
|
98
|
+
|
|
99
|
+
# Check if this pad file has any unconsumed pads
|
|
100
|
+
has_unconsumed = pads_hash.values.any? { |pad| !pad["consumed"] }
|
|
101
|
+
next if require_unconsumed && !has_unconsumed
|
|
102
|
+
|
|
103
|
+
# Find first pad (unconsumed if require_unconsumed is true)
|
|
104
|
+
pads_hash.each do |pad_num_str, pad|
|
|
105
|
+
next if require_unconsumed && pad["consumed"]
|
|
106
|
+
|
|
107
|
+
# Check if pad is long enough if min_length is specified
|
|
108
|
+
if min_length
|
|
109
|
+
pad_key_length = pad["key"].length / 2 # Convert hex length to byte length
|
|
110
|
+
next if pad_key_length < min_length
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
pad_path: file_path,
|
|
115
|
+
pad_num: pad_num_str,
|
|
116
|
+
pad_id: pad_data["id"].to_s
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
error_msg = require_unconsumed ? "No available (unconsumed) pads found" : "No pads found"
|
|
122
|
+
error_msg += agent_name ? " for agent '#{agent_name}'" : ""
|
|
123
|
+
raise ArgumentError, "#{error_msg} in #{pads_dir}"
|
|
124
|
+
rescue JSON::ParserError => e
|
|
125
|
+
NumberStation.log.error "Failed to parse pad file: #{e.message}"
|
|
126
|
+
raise
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
NumberStation.log.error "Error finding pad: #{e.message}"
|
|
129
|
+
raise
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def self.examine_pad_file(file_path)
|
|
133
|
+
pad_data = JSON.parse(File.read(file_path))
|
|
134
|
+
filename = File.basename(file_path)
|
|
135
|
+
|
|
136
|
+
# Pad data structure: { "id" => "...", "pads" => { "0" => {...}, "1" => {...}, ... } }
|
|
137
|
+
pads_hash = pad_data["pads"]
|
|
138
|
+
|
|
139
|
+
if pads_hash.nil? || pads_hash.empty?
|
|
140
|
+
return {
|
|
141
|
+
filename: filename,
|
|
142
|
+
error: "No pads found in file"
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Get max message length from first pad's key length
|
|
147
|
+
# Key is hex string, so bytes = hex_length / 2
|
|
148
|
+
# Max message length in characters equals pad length in bytes
|
|
149
|
+
first_pad_key = pads_hash.values.first["key"]
|
|
150
|
+
max_message_length = first_pad_key.length / 2
|
|
151
|
+
|
|
152
|
+
# Count unconsumed pads
|
|
153
|
+
unconsumed_count = pads_hash.values.count { |pad| !pad["consumed"] }
|
|
154
|
+
total_pads = pads_hash.size
|
|
155
|
+
|
|
156
|
+
{
|
|
157
|
+
filename: filename,
|
|
158
|
+
pad_id: pad_data["id"].to_s,
|
|
159
|
+
max_message_length: max_message_length,
|
|
160
|
+
total_pads: total_pads,
|
|
161
|
+
unconsumed_pads: unconsumed_count,
|
|
162
|
+
consumed_pads: total_pads - unconsumed_count
|
|
163
|
+
}
|
|
164
|
+
rescue StandardError => e
|
|
165
|
+
NumberStation.log.error "Failed to examine pad file #{file_path}: #{e.message}"
|
|
166
|
+
{
|
|
167
|
+
filename: File.basename(file_path),
|
|
168
|
+
error: e.message
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
end
|