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 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
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "opa/cli"
5
+
6
+ exit OPA::CLI.new(ARGV).run
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OPA
4
+ VERSION = "0.1.0"
5
+ end
data/lib/opa.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "opa/version"
4
+ require_relative "opa/manifest"
5
+ require_relative "opa/archive"
6
+ require_relative "opa/signer"
7
+ require_relative "opa/verifier"
8
+
9
+ module OPA
10
+ OPA_VERSION = "0.1"
11
+ end
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: []