opa-ruby 0.1.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 +7 -0
- data/bin/opa +6 -0
- data/lib/opa/archive.rb +101 -0
- data/lib/opa/cli.rb +210 -0
- data/lib/opa/manifest.rb +110 -0
- data/lib/opa/signer.rb +90 -0
- data/lib/opa/verifier.rb +209 -0
- data/lib/opa/version.rb +5 -0
- data/lib/opa.rb +11 -0
- metadata +96 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6938bad1dc3db52fbac52f2a3c9df453fb03a9d61f937a33f3280d4fb258fb46
|
|
4
|
+
data.tar.gz: 25d52c72af1b58ff16e44f20bd4c49e955cfaa4f01a0002faea1498ac1ee2d2e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b7b3dc7a81c10bfc9e397bf94e4cf206a254db500a82040e4babedc36904cc9d9c42b806f93f69361efe544c16dcdd844a8f72c55e03df8fc5dbc25d0cabbf5b
|
|
7
|
+
data.tar.gz: de0c56f213ecc9bb17edbb4d8bdd26323b5afe33bc71d98c4dfccb001578496f9d98a90ee7d13d936b04fe356105ed57356500552a340882a6c697216b8e97da
|
data/bin/opa
ADDED
data/lib/opa/archive.rb
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zip"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module OPA
|
|
7
|
+
# Builds OPA archive (.opa) files.
|
|
8
|
+
# An OPA archive is a ZIP file containing a manifest, prompt file,
|
|
9
|
+
# and optional session history and data assets.
|
|
10
|
+
class Archive
|
|
11
|
+
attr_reader :manifest
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@manifest = Manifest.new
|
|
15
|
+
@entries = {} # path => content (String or IO)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def prompt=(content)
|
|
19
|
+
prompt_path = @manifest["Prompt-File"] || "prompt.md"
|
|
20
|
+
@entries[prompt_path] = content
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def add_data(path, content)
|
|
24
|
+
full_path = "data/#{path}"
|
|
25
|
+
@entries[full_path] = content
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def add_session(content)
|
|
29
|
+
session_path = @manifest["Session-File"] || "session/history.json"
|
|
30
|
+
@entries[session_path] = content
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def add_entry(path, content)
|
|
34
|
+
@entries[path] = content
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Sign the archive with a private key and certificate.
|
|
38
|
+
# Must be called before #write.
|
|
39
|
+
def sign(private_key, certificate, algorithm: "SHA-256")
|
|
40
|
+
@signer = Signer.new(private_key, certificate, algorithm: algorithm)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def write(io_or_path)
|
|
44
|
+
if io_or_path.is_a?(String)
|
|
45
|
+
File.open(io_or_path, "wb") { |f| write_to_stream(f) }
|
|
46
|
+
else
|
|
47
|
+
write_to_stream(io_or_path)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def write_to_stream(io)
|
|
54
|
+
buffer = StringIO.new
|
|
55
|
+
build_zip(buffer)
|
|
56
|
+
io.write(buffer.string)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_zip(io)
|
|
60
|
+
Zip::OutputStream.write_buffer(io) do |zip|
|
|
61
|
+
# Compute digests for all entries if signing
|
|
62
|
+
entry_digests = {}
|
|
63
|
+
@entries.each do |path, content|
|
|
64
|
+
data = content.is_a?(IO) ? content.read : content
|
|
65
|
+
entry_digests[path] = data
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Build manifest with digests if signing
|
|
69
|
+
if @signer
|
|
70
|
+
entry_digests.each do |path, data|
|
|
71
|
+
digest = @signer.digest(data)
|
|
72
|
+
@manifest.add_entry(path, { "#{@signer.digest_name}-Digest" => digest })
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
manifest_content = @manifest.to_s
|
|
77
|
+
|
|
78
|
+
# Write manifest
|
|
79
|
+
zip.put_next_entry("META-INF/MANIFEST.MF")
|
|
80
|
+
zip.write(manifest_content)
|
|
81
|
+
|
|
82
|
+
# Write signature files if signing
|
|
83
|
+
if @signer
|
|
84
|
+
sf_content = @signer.signature_file(manifest_content)
|
|
85
|
+
zip.put_next_entry("META-INF/SIGNATURE.SF")
|
|
86
|
+
zip.write(sf_content)
|
|
87
|
+
|
|
88
|
+
sig_block = @signer.signature_block(sf_content)
|
|
89
|
+
zip.put_next_entry("META-INF/SIGNATURE#{@signer.block_extension}")
|
|
90
|
+
zip.write(sig_block)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Write all content entries
|
|
94
|
+
entry_digests.each do |path, data|
|
|
95
|
+
zip.put_next_entry(path)
|
|
96
|
+
zip.write(data)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
data/lib/opa/cli.rb
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "opa"
|
|
5
|
+
|
|
6
|
+
module OPA
|
|
7
|
+
class CLI
|
|
8
|
+
COMMANDS = %w[pack verify inspect].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(argv)
|
|
11
|
+
@argv = argv.dup
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run
|
|
15
|
+
if @argv.empty? || %w[-h --help help].include?(@argv.first)
|
|
16
|
+
print_usage
|
|
17
|
+
return 0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
if %w[-v --version].include?(@argv.first)
|
|
21
|
+
$stdout.puts "opa-ruby #{OPA::VERSION}"
|
|
22
|
+
return 0
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
command = @argv.shift
|
|
26
|
+
unless COMMANDS.include?(command)
|
|
27
|
+
$stderr.puts "Unknown command: #{command}"
|
|
28
|
+
$stderr.puts "Run 'opa --help' for usage."
|
|
29
|
+
return 1
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
send("run_#{command}")
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
$stderr.puts "Error: #{e.message}"
|
|
35
|
+
1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def run_pack
|
|
41
|
+
options = { title: nil, data: [], session: nil, key: nil, cert: nil, algorithm: "SHA-256", output: nil }
|
|
42
|
+
|
|
43
|
+
parser = OptionParser.new do |opts|
|
|
44
|
+
opts.banner = "Usage: opa pack PROMPT_FILE [options]"
|
|
45
|
+
opts.on("-o", "--output FILE", "Output .opa file (default: <prompt_basename>.opa)") { |v| options[:output] = v }
|
|
46
|
+
opts.on("-t", "--title TITLE", "Archive title") { |v| options[:title] = v }
|
|
47
|
+
opts.on("-d", "--data FILE", "Add a data file (repeatable)") { |v| options[:data] << v }
|
|
48
|
+
opts.on("-s", "--session FILE", "Session history JSON file") { |v| options[:session] = v }
|
|
49
|
+
opts.on("-k", "--key FILE", "Private key PEM file for signing") { |v| options[:key] = v }
|
|
50
|
+
opts.on("-c", "--cert FILE", "Certificate PEM file for signing") { |v| options[:cert] = v }
|
|
51
|
+
opts.on("--algorithm ALG", "Digest algorithm (SHA-256, SHA-384, SHA-512)") { |v| options[:algorithm] = v }
|
|
52
|
+
end
|
|
53
|
+
parser.parse!(@argv)
|
|
54
|
+
|
|
55
|
+
prompt_file = @argv.shift
|
|
56
|
+
unless prompt_file
|
|
57
|
+
$stderr.puts parser.help
|
|
58
|
+
return 1
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
unless File.exist?(prompt_file)
|
|
62
|
+
$stderr.puts "Prompt file not found: #{prompt_file}"
|
|
63
|
+
return 1
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
archive = Archive.new
|
|
67
|
+
archive.manifest["Prompt-File"] = File.basename(prompt_file)
|
|
68
|
+
archive.manifest["Created-By"] = "opa-ruby/#{OPA::VERSION}"
|
|
69
|
+
archive.manifest["Title"] = options[:title] if options[:title]
|
|
70
|
+
archive.prompt = File.read(prompt_file)
|
|
71
|
+
|
|
72
|
+
options[:data].each do |data_file|
|
|
73
|
+
unless File.exist?(data_file)
|
|
74
|
+
$stderr.puts "Data file not found: #{data_file}"
|
|
75
|
+
return 1
|
|
76
|
+
end
|
|
77
|
+
archive.add_data(File.basename(data_file), File.read(data_file))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if options[:session]
|
|
81
|
+
unless File.exist?(options[:session])
|
|
82
|
+
$stderr.puts "Session file not found: #{options[:session]}"
|
|
83
|
+
return 1
|
|
84
|
+
end
|
|
85
|
+
archive.add_session(File.read(options[:session]))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
if options[:key] || options[:cert]
|
|
89
|
+
unless options[:key] && options[:cert]
|
|
90
|
+
$stderr.puts "Both --key and --cert are required for signing."
|
|
91
|
+
return 1
|
|
92
|
+
end
|
|
93
|
+
key = OpenSSL::PKey.read(File.read(options[:key]))
|
|
94
|
+
cert = OpenSSL::X509::Certificate.new(File.read(options[:cert]))
|
|
95
|
+
archive.sign(key, cert, algorithm: options[:algorithm])
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
output = options[:output] || File.basename(prompt_file, File.extname(prompt_file)) + ".opa"
|
|
99
|
+
archive.write(output)
|
|
100
|
+
$stdout.puts "Created #{output}"
|
|
101
|
+
0
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def run_verify
|
|
105
|
+
options = { cert: nil }
|
|
106
|
+
|
|
107
|
+
parser = OptionParser.new do |opts|
|
|
108
|
+
opts.banner = "Usage: opa verify ARCHIVE [options]"
|
|
109
|
+
opts.on("-c", "--cert FILE", "Certificate PEM file to verify against") { |v| options[:cert] = v }
|
|
110
|
+
end
|
|
111
|
+
parser.parse!(@argv)
|
|
112
|
+
|
|
113
|
+
archive_file = @argv.shift
|
|
114
|
+
unless archive_file
|
|
115
|
+
$stderr.puts parser.help
|
|
116
|
+
return 1
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
unless File.exist?(archive_file)
|
|
120
|
+
$stderr.puts "Archive not found: #{archive_file}"
|
|
121
|
+
return 1
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
verifier = Verifier.new(archive_file)
|
|
125
|
+
|
|
126
|
+
unless verifier.signed?
|
|
127
|
+
$stderr.puts "Archive is not signed."
|
|
128
|
+
return 1
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
cert = nil
|
|
132
|
+
if options[:cert]
|
|
133
|
+
cert = OpenSSL::X509::Certificate.new(File.read(options[:cert]))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
verifier.verify!(certificate: cert)
|
|
137
|
+
$stdout.puts "Signature valid."
|
|
138
|
+
0
|
|
139
|
+
rescue SignatureError => e
|
|
140
|
+
$stderr.puts "Verification failed: #{e.message}"
|
|
141
|
+
1
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def run_inspect
|
|
145
|
+
parser = OptionParser.new do |opts|
|
|
146
|
+
opts.banner = "Usage: opa inspect ARCHIVE"
|
|
147
|
+
end
|
|
148
|
+
parser.parse!(@argv)
|
|
149
|
+
|
|
150
|
+
archive_file = @argv.shift
|
|
151
|
+
unless archive_file
|
|
152
|
+
$stderr.puts parser.help
|
|
153
|
+
return 1
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
unless File.exist?(archive_file)
|
|
157
|
+
$stderr.puts "Archive not found: #{archive_file}"
|
|
158
|
+
return 1
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
verifier = Verifier.new(archive_file)
|
|
162
|
+
manifest = verifier.manifest
|
|
163
|
+
|
|
164
|
+
$stdout.puts "Archive: #{archive_file}"
|
|
165
|
+
$stdout.puts "Signed: #{verifier.signed? ? "yes" : "no"}"
|
|
166
|
+
$stdout.puts ""
|
|
167
|
+
$stdout.puts "Manifest Attributes:"
|
|
168
|
+
manifest.main_attributes.each do |key, value|
|
|
169
|
+
$stdout.puts " #{key}: #{value}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
if manifest.entries.any?
|
|
173
|
+
$stdout.puts ""
|
|
174
|
+
$stdout.puts "Entries:"
|
|
175
|
+
manifest.entries.each_key do |name|
|
|
176
|
+
$stdout.puts " #{name}"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
prompt = verifier.prompt
|
|
181
|
+
if prompt
|
|
182
|
+
$stdout.puts ""
|
|
183
|
+
$stdout.puts "Prompt:"
|
|
184
|
+
prompt.each_line.first(5).each { |l| $stdout.puts " #{l}" }
|
|
185
|
+
$stdout.puts " ..." if prompt.lines.size > 5
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
0
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def print_usage
|
|
192
|
+
$stdout.puts <<~USAGE
|
|
193
|
+
opa-ruby #{OPA::VERSION} - Open Prompt Archive tool
|
|
194
|
+
|
|
195
|
+
Usage: opa <command> [options]
|
|
196
|
+
|
|
197
|
+
Commands:
|
|
198
|
+
pack Create an OPA archive from a prompt file
|
|
199
|
+
verify Verify a signed OPA archive
|
|
200
|
+
inspect Show archive contents and metadata
|
|
201
|
+
|
|
202
|
+
Options:
|
|
203
|
+
-v, --version Show version
|
|
204
|
+
-h, --help Show this help
|
|
205
|
+
|
|
206
|
+
Run 'opa <command> --help' for command-specific options.
|
|
207
|
+
USAGE
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
data/lib/opa/manifest.rb
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OPA
|
|
4
|
+
# Represents a META-INF/MANIFEST.MF file in JAR/OPA format.
|
|
5
|
+
# Handles parsing and generating manifest content with main attributes
|
|
6
|
+
# and per-entry sections (used for signing digests).
|
|
7
|
+
class Manifest
|
|
8
|
+
MAIN_ATTRS_ORDER = %w[
|
|
9
|
+
Manifest-Version OPA-Version Prompt-File Title Description
|
|
10
|
+
Created-By Created-At Agent-Hint Execution-Mode Session-File
|
|
11
|
+
Data-Root Schema-Extensions
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
LINE_WIDTH = 72
|
|
15
|
+
|
|
16
|
+
attr_reader :main_attributes, :entries
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@main_attributes = {
|
|
20
|
+
"Manifest-Version" => "1.0",
|
|
21
|
+
"OPA-Version" => OPA::OPA_VERSION
|
|
22
|
+
}
|
|
23
|
+
@entries = {} # name => { attr => value }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def []=(key, value)
|
|
27
|
+
@main_attributes[key] = value
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def [](key)
|
|
31
|
+
@main_attributes[key]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def add_entry(name, attributes = {})
|
|
35
|
+
@entries[name] = attributes
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_s
|
|
39
|
+
lines = []
|
|
40
|
+
|
|
41
|
+
# Main section - ordered attributes first, then any extras
|
|
42
|
+
ordered_keys = MAIN_ATTRS_ORDER & @main_attributes.keys
|
|
43
|
+
extra_keys = @main_attributes.keys - MAIN_ATTRS_ORDER
|
|
44
|
+
(ordered_keys + extra_keys).each do |key|
|
|
45
|
+
lines << wrap_line("#{key}: #{@main_attributes[key]}")
|
|
46
|
+
end
|
|
47
|
+
lines << ""
|
|
48
|
+
|
|
49
|
+
# Per-entry sections
|
|
50
|
+
@entries.each do |name, attrs|
|
|
51
|
+
lines << wrap_line("Name: #{name}")
|
|
52
|
+
attrs.each do |key, value|
|
|
53
|
+
lines << wrap_line("#{key}: #{value}")
|
|
54
|
+
end
|
|
55
|
+
lines << ""
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
lines.join("\n")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.parse(text)
|
|
62
|
+
manifest = new
|
|
63
|
+
sections = text.split(/\n(?=Name: )/m)
|
|
64
|
+
|
|
65
|
+
# Parse main section
|
|
66
|
+
main_section = sections.shift || ""
|
|
67
|
+
parse_section(main_section).each do |key, value|
|
|
68
|
+
manifest[key] = value
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Parse entry sections
|
|
72
|
+
sections.each do |section|
|
|
73
|
+
attrs = parse_section(section)
|
|
74
|
+
name = attrs.delete("Name")
|
|
75
|
+
manifest.add_entry(name, attrs) if name
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
manifest
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def wrap_line(line)
|
|
84
|
+
return line if line.bytesize <= LINE_WIDTH
|
|
85
|
+
|
|
86
|
+
result = line.byteslice(0, LINE_WIDTH)
|
|
87
|
+
pos = LINE_WIDTH
|
|
88
|
+
while pos < line.bytesize
|
|
89
|
+
chunk = line.byteslice(pos, LINE_WIDTH - 1)
|
|
90
|
+
result += "\n #{chunk}"
|
|
91
|
+
pos += LINE_WIDTH - 1
|
|
92
|
+
end
|
|
93
|
+
result
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.parse_section(text)
|
|
97
|
+
# Unfold continuation lines (lines starting with a single space)
|
|
98
|
+
unfolded = text.gsub(/\n /, "")
|
|
99
|
+
attrs = {}
|
|
100
|
+
unfolded.each_line do |line|
|
|
101
|
+
line = line.chomp
|
|
102
|
+
next if line.empty?
|
|
103
|
+
if line =~ /\A([A-Za-z0-9_-]+):\s*(.*)\z/
|
|
104
|
+
attrs[Regexp.last_match(1)] = Regexp.last_match(2)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
attrs
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
data/lib/opa/signer.rb
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
7
|
+
module OPA
|
|
8
|
+
# Signs OPA archives using JAR-format digital signatures.
|
|
9
|
+
# Produces META-INF/SIGNATURE.SF and a signature block file (.RSA, .DSA, or .EC).
|
|
10
|
+
class Signer
|
|
11
|
+
SUPPORTED_DIGESTS = {
|
|
12
|
+
"SHA-256" => OpenSSL::Digest::SHA256,
|
|
13
|
+
"SHA-384" => OpenSSL::Digest::SHA384,
|
|
14
|
+
"SHA-512" => OpenSSL::Digest::SHA512
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
REJECTED_DIGESTS = %w[MD5 SHA-1].freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :digest_name
|
|
20
|
+
|
|
21
|
+
def initialize(private_key, certificate, algorithm: "SHA-256")
|
|
22
|
+
raise ArgumentError, "Digest #{algorithm} is not allowed" if REJECTED_DIGESTS.include?(algorithm)
|
|
23
|
+
raise ArgumentError, "Unsupported digest: #{algorithm}" unless SUPPORTED_DIGESTS.key?(algorithm)
|
|
24
|
+
|
|
25
|
+
@private_key = private_key
|
|
26
|
+
@certificate = certificate
|
|
27
|
+
@digest_name = algorithm
|
|
28
|
+
@digest_class = SUPPORTED_DIGESTS[algorithm]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Compute the Base64-encoded digest of data.
|
|
32
|
+
def digest(data)
|
|
33
|
+
Base64.strict_encode64(@digest_class.digest(data))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Generate the SIGNATURE.SF content from manifest content.
|
|
37
|
+
# Contains a digest of the entire manifest and per-entry section digests.
|
|
38
|
+
def signature_file(manifest_content)
|
|
39
|
+
lines = []
|
|
40
|
+
lines << "Signature-Version: 1.0"
|
|
41
|
+
lines << "#{@digest_name}-Digest-Manifest: #{digest(manifest_content)}"
|
|
42
|
+
lines << ""
|
|
43
|
+
|
|
44
|
+
# Compute per-entry section digests
|
|
45
|
+
sections = extract_entry_sections(manifest_content)
|
|
46
|
+
sections.each do |name, section_text|
|
|
47
|
+
lines << "Name: #{name}"
|
|
48
|
+
lines << "#{@digest_name}-Digest: #{digest(section_text)}"
|
|
49
|
+
lines << ""
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
lines.join("\n")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Create the PKCS#7 signature block over the SF content.
|
|
56
|
+
def signature_block(sf_content)
|
|
57
|
+
flags = OpenSSL::PKCS7::BINARY | OpenSSL::PKCS7::DETACHED | OpenSSL::PKCS7::NOSMIMECAP
|
|
58
|
+
pkcs7 = OpenSSL::PKCS7.sign(@certificate, @private_key, sf_content, [], flags)
|
|
59
|
+
pkcs7.to_der
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# File extension for the signature block based on key type.
|
|
63
|
+
def block_extension
|
|
64
|
+
case @private_key
|
|
65
|
+
when OpenSSL::PKey::RSA then ".RSA"
|
|
66
|
+
when OpenSSL::PKey::DSA then ".DSA"
|
|
67
|
+
when OpenSSL::PKey::EC then ".EC"
|
|
68
|
+
else ".RSA"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Extract individual entry sections from manifest text.
|
|
75
|
+
# Each section starts with "Name: " and ends at the next blank line.
|
|
76
|
+
def extract_entry_sections(manifest_content)
|
|
77
|
+
sections = {}
|
|
78
|
+
# Split on double newline to get sections, keep the trailing newline
|
|
79
|
+
parts = manifest_content.split(/(?=Name: )/m)
|
|
80
|
+
parts.each do |part|
|
|
81
|
+
next unless part.start_with?("Name: ")
|
|
82
|
+
name_line = part.lines.first.chomp
|
|
83
|
+
name = name_line.sub(/\AName: /, "")
|
|
84
|
+
# Section text includes the trailing newline (important for digest)
|
|
85
|
+
sections[name] = part
|
|
86
|
+
end
|
|
87
|
+
sections
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
data/lib/opa/verifier.rb
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zip"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "base64"
|
|
6
|
+
require "digest"
|
|
7
|
+
|
|
8
|
+
module OPA
|
|
9
|
+
# Reads and verifies OPA archives.
|
|
10
|
+
# Supports signature verification using JAR-format signing.
|
|
11
|
+
class Verifier
|
|
12
|
+
SUPPORTED_DIGESTS = Signer::SUPPORTED_DIGESTS
|
|
13
|
+
REJECTED_DIGESTS = Signer::REJECTED_DIGESTS
|
|
14
|
+
|
|
15
|
+
attr_reader :manifest, :entries
|
|
16
|
+
|
|
17
|
+
def initialize(io_or_path)
|
|
18
|
+
@raw_entries = {}
|
|
19
|
+
if io_or_path.is_a?(String)
|
|
20
|
+
File.open(io_or_path, "rb") { |f| read_zip(f) }
|
|
21
|
+
else
|
|
22
|
+
read_zip(io_or_path)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def signed?
|
|
27
|
+
@raw_entries.key?("META-INF/SIGNATURE.SF")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Verify the archive signature. Returns true if valid.
|
|
31
|
+
# Raises OPA::SignatureError on failure.
|
|
32
|
+
def verify!(certificate: nil)
|
|
33
|
+
raise SignatureError, "Archive is not signed" unless signed?
|
|
34
|
+
|
|
35
|
+
sf_content = @raw_entries["META-INF/SIGNATURE.SF"]
|
|
36
|
+
sf_attrs = parse_sf(sf_content)
|
|
37
|
+
|
|
38
|
+
# Find and verify the signature block
|
|
39
|
+
block_entry = find_signature_block
|
|
40
|
+
raise SignatureError, "No signature block file found" unless block_entry
|
|
41
|
+
|
|
42
|
+
block_data = @raw_entries[block_entry]
|
|
43
|
+
verify_pkcs7_signature(block_data, sf_content, certificate)
|
|
44
|
+
|
|
45
|
+
# Verify manifest digest
|
|
46
|
+
manifest_content = @raw_entries["META-INF/MANIFEST.MF"]
|
|
47
|
+
digest_name = detect_digest_algorithm(sf_attrs[:main])
|
|
48
|
+
verify_digest(digest_name, manifest_content, sf_attrs[:main]["#{digest_name}-Digest-Manifest"],
|
|
49
|
+
"Manifest digest mismatch")
|
|
50
|
+
|
|
51
|
+
# Verify individual entry digests
|
|
52
|
+
sf_attrs[:entries].each do |name, attrs|
|
|
53
|
+
entry_digest_name = detect_digest_algorithm(attrs)
|
|
54
|
+
manifest_section = extract_manifest_section(manifest_content, name)
|
|
55
|
+
raise SignatureError, "No manifest section for #{name}" unless manifest_section
|
|
56
|
+
|
|
57
|
+
verify_digest(entry_digest_name, manifest_section, attrs["#{entry_digest_name}-Digest"],
|
|
58
|
+
"Section digest mismatch for #{name}")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Verify manifest entry digests against actual content
|
|
62
|
+
@manifest.entries.each do |name, attrs|
|
|
63
|
+
entry_digest_name = detect_digest_algorithm(attrs)
|
|
64
|
+
next unless entry_digest_name
|
|
65
|
+
|
|
66
|
+
expected = attrs["#{entry_digest_name}-Digest"]
|
|
67
|
+
next unless expected
|
|
68
|
+
|
|
69
|
+
actual_content = @raw_entries[name]
|
|
70
|
+
raise SignatureError, "Missing entry: #{name}" unless actual_content
|
|
71
|
+
|
|
72
|
+
verify_digest(entry_digest_name, actual_content, expected,
|
|
73
|
+
"Content digest mismatch for #{name}")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Read a specific entry from the archive.
|
|
80
|
+
def read_entry(path)
|
|
81
|
+
@raw_entries[path]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def prompt
|
|
85
|
+
prompt_path = @manifest["Prompt-File"] || "prompt.md"
|
|
86
|
+
@raw_entries[prompt_path]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def session
|
|
90
|
+
session_path = @manifest["Session-File"] || "session/history.json"
|
|
91
|
+
@raw_entries[session_path]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def data_entries
|
|
95
|
+
prefix = @manifest["Data-Root"] || "data/"
|
|
96
|
+
@raw_entries.select { |k, _| k.start_with?(prefix) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def read_zip(io)
|
|
102
|
+
data = io.read
|
|
103
|
+
Zip::InputStream.open(StringIO.new(data)) do |zip|
|
|
104
|
+
while (entry = zip.get_next_entry)
|
|
105
|
+
next if entry.directory?
|
|
106
|
+
path = entry.name
|
|
107
|
+
validate_path!(path)
|
|
108
|
+
@raw_entries[path] = zip.read
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
manifest_content = @raw_entries["META-INF/MANIFEST.MF"]
|
|
113
|
+
raise "Missing META-INF/MANIFEST.MF" unless manifest_content
|
|
114
|
+
|
|
115
|
+
@manifest = Manifest.parse(manifest_content)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def validate_path!(path)
|
|
119
|
+
raise SignatureError, "Path traversal detected: #{path}" if path.include?("..") || path.start_with?("/")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def find_signature_block
|
|
123
|
+
%w[.RSA .DSA .EC].each do |ext|
|
|
124
|
+
key = "META-INF/SIGNATURE#{ext}"
|
|
125
|
+
return key if @raw_entries.key?(key)
|
|
126
|
+
end
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def verify_pkcs7_signature(block_data, sf_content, certificate)
|
|
131
|
+
pkcs7 = OpenSSL::PKCS7.new(block_data)
|
|
132
|
+
|
|
133
|
+
store = OpenSSL::X509::Store.new
|
|
134
|
+
if certificate
|
|
135
|
+
store.add_cert(certificate)
|
|
136
|
+
else
|
|
137
|
+
# Extract the certificate from the PKCS7 structure
|
|
138
|
+
pkcs7.certificates&.each { |c| store.add_cert(c) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
flags = OpenSSL::PKCS7::BINARY | OpenSSL::PKCS7::NOVERIFY
|
|
142
|
+
flags = OpenSSL::PKCS7::BINARY if certificate
|
|
143
|
+
|
|
144
|
+
unless pkcs7.verify(certificate ? [certificate] : [], store, sf_content, flags)
|
|
145
|
+
raise SignatureError, "Digital signature verification failed"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def parse_sf(content)
|
|
150
|
+
main_attrs = {}
|
|
151
|
+
entries = {}
|
|
152
|
+
|
|
153
|
+
sections = content.split(/(?=Name: )/m)
|
|
154
|
+
main_section = sections.shift || ""
|
|
155
|
+
|
|
156
|
+
main_section.each_line do |line|
|
|
157
|
+
line = line.chomp
|
|
158
|
+
next if line.empty?
|
|
159
|
+
if line =~ /\A([A-Za-z0-9_-]+):\s*(.*)\z/
|
|
160
|
+
main_attrs[Regexp.last_match(1)] = Regexp.last_match(2)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
sections.each do |section|
|
|
165
|
+
attrs = {}
|
|
166
|
+
name = nil
|
|
167
|
+
section.each_line do |line|
|
|
168
|
+
line = line.chomp
|
|
169
|
+
next if line.empty?
|
|
170
|
+
if line =~ /\A([A-Za-z0-9_-]+):\s*(.*)\z/
|
|
171
|
+
key, val = Regexp.last_match(1), Regexp.last_match(2)
|
|
172
|
+
if key == "Name"
|
|
173
|
+
name = val
|
|
174
|
+
else
|
|
175
|
+
attrs[key] = val
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
entries[name] = attrs if name
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
{ main: main_attrs, entries: entries }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def detect_digest_algorithm(attrs)
|
|
186
|
+
SUPPORTED_DIGESTS.each_key do |name|
|
|
187
|
+
return name if attrs.any? { |k, _| k.include?(name) }
|
|
188
|
+
end
|
|
189
|
+
raise SignatureError, "No supported digest algorithm found"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def verify_digest(algorithm, data, expected_b64, message)
|
|
193
|
+
raise SignatureError, "Rejected digest algorithm: #{algorithm}" if REJECTED_DIGESTS.include?(algorithm)
|
|
194
|
+
|
|
195
|
+
digest_class = SUPPORTED_DIGESTS[algorithm]
|
|
196
|
+
raise SignatureError, "Unsupported digest: #{algorithm}" unless digest_class
|
|
197
|
+
|
|
198
|
+
actual = Base64.strict_encode64(digest_class.digest(data))
|
|
199
|
+
raise SignatureError, "#{message}: expected #{expected_b64}, got #{actual}" unless actual == expected_b64
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def extract_manifest_section(manifest_content, name)
|
|
203
|
+
parts = manifest_content.split(/(?=Name: )/m)
|
|
204
|
+
parts.find { |p| p.start_with?("Name: #{name}\n") || p.start_with?("Name: #{name}\r\n") }
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
class SignatureError < StandardError; end
|
|
209
|
+
end
|
data/lib/opa/version.rb
ADDED
data/lib/opa.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: opa-ruby
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- OPA Ruby Contributors
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-03-08 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rubyzip
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.0'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '3.0'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '2.0'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '3.0'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: rspec
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - "~>"
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '3.0'
|
|
39
|
+
type: :development
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - "~>"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '3.0'
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: rake
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - "~>"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '13.0'
|
|
53
|
+
type: :development
|
|
54
|
+
prerelease: false
|
|
55
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - "~>"
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '13.0'
|
|
60
|
+
description: Provides tools for building, signing, and verifying OPA archives — portable
|
|
61
|
+
ZIP-based packages for AI agent prompts, session history, and data assets.
|
|
62
|
+
executables:
|
|
63
|
+
- opa
|
|
64
|
+
extensions: []
|
|
65
|
+
extra_rdoc_files: []
|
|
66
|
+
files:
|
|
67
|
+
- bin/opa
|
|
68
|
+
- lib/opa.rb
|
|
69
|
+
- lib/opa/archive.rb
|
|
70
|
+
- lib/opa/cli.rb
|
|
71
|
+
- lib/opa/manifest.rb
|
|
72
|
+
- lib/opa/signer.rb
|
|
73
|
+
- lib/opa/verifier.rb
|
|
74
|
+
- lib/opa/version.rb
|
|
75
|
+
homepage: https://github.com/shannah/opa-ruby
|
|
76
|
+
licenses:
|
|
77
|
+
- MIT
|
|
78
|
+
metadata: {}
|
|
79
|
+
rdoc_options: []
|
|
80
|
+
require_paths:
|
|
81
|
+
- lib
|
|
82
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
83
|
+
requirements:
|
|
84
|
+
- - ">="
|
|
85
|
+
- !ruby/object:Gem::Version
|
|
86
|
+
version: 2.7.0
|
|
87
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
88
|
+
requirements:
|
|
89
|
+
- - ">="
|
|
90
|
+
- !ruby/object:Gem::Version
|
|
91
|
+
version: '0'
|
|
92
|
+
requirements: []
|
|
93
|
+
rubygems_version: 3.6.3
|
|
94
|
+
specification_version: 4
|
|
95
|
+
summary: A Ruby library for generating Open Prompt Archive (OPA) files
|
|
96
|
+
test_files: []
|