Module: Pray::Trust
- Defined in:
- lib/pray/trust.rb,
lib/pray/trust_ops.rb,
lib/pray/trust_feed.rb
Constant Summary collapse
- DEFAULT_COMPROMISED_KEYS_SOURCE =
"https://raw.githubusercontent.com/bmx-rs/trust-lists/main/compromised-keys.toml"
Class Method Summary collapse
- .add_allowed_signing_key(key, match_prefix: nil, home: trust_home) ⇒ Object
- .append_missing!(target, keys) ⇒ Object
- .best_rule(policy, source_url) ⇒ Object
- .check_compromised(source = nil, home: trust_home) ⇒ Object
- .compromised_hits(home, entries) ⇒ Object
- .fetch_compromised_feed(source) ⇒ Object
- .fetch_ssh_publisher_fingerprints(source_url) ⇒ Object
- .fingerprint_matches?(allowed, candidate) ⇒ Boolean
- .format_keyed_list(name, values) ⇒ Object
- .format_policy(policy) ⇒ Object
- .format_rule_block(scope, rule) ⇒ Object
- .format_rule_lines(rule) ⇒ Object
- .git_format(repository, format) ⇒ Object
- .import_registry(source_url, match_prefix: nil, include_host_key: false, home: trust_home) ⇒ Object
- .import_repo(source_url, repository, match_prefix: nil, home: trust_home) ⇒ Object
- .list_policy(scope:, source_url: nil, home: trust_home) ⇒ Object
- .list_policy_all(policy, scope) ⇒ Object
- .list_policy_for_source(policy, scope, source_url) ⇒ Object
- .load_policy(home = trust_home) ⇒ Object
- .load_policy_or_default(home = trust_home) ⇒ Object
- .mutable_rule_for_match_prefix!(policy, match_prefix) ⇒ Object
- .normalize_key(value) ⇒ Object
- .parse_compromised_feed(body, source_hint) ⇒ Object
- .parse_compromised_toml(body) ⇒ Object
- .parse_compromised_txt(body) ⇒ Object
- .parse_policy(data) ⇒ Object
- .parse_rule(data) ⇒ Object
- .prepare_source_host_keys(sources) ⇒ Object
- .read_distribution_bytes(source_url, relative_path) ⇒ Object
- .remove_allowed_signing_key(key, match_prefix: nil, home: trust_home) ⇒ Object
- .repository_signing_keys(repository) ⇒ Object
- .save_policy(policy, home = trust_home) ⇒ Object
- .set_allow(match_prefix, allow, home: trust_home) ⇒ Object
- .set_require_signed_commit(match_prefix, enabled, home: trust_home) ⇒ Object
- .show_policy_toml(home = trust_home) ⇒ Object
- .toml_array(values) ⇒ Object
- .trust_home ⇒ Object
- .trust_policy_path(home = trust_home) ⇒ Object
- .trusted_keys_by_scope(home) ⇒ Object
- .verify_publisher_fingerprint!(source_url, selected) ⇒ Object
Class Method Details
.add_allowed_signing_key(key, match_prefix: nil, home: trust_home) ⇒ Object
18 19 20 21 22 23 24 25 26 27 28 |
# File 'lib/pray/trust_ops.rb', line 18 def add_allowed_signing_key(key, match_prefix: nil, home: trust_home) normalized = normalize_key(key) raise Error.unsupported("signing key is empty") if normalized.empty? policy = load_policy_or_default(home) rule = match_prefix ? mutable_rule_for_match_prefix!(policy, match_prefix) : policy.default_rule unless rule.allowed_signing_keys.any? { |existing| normalize_key(existing) == normalized } rule.allowed_signing_keys << normalized end save_policy(policy, home) end |
.append_missing!(target, keys) ⇒ Object
186 187 188 189 190 191 192 193 194 195 196 197 |
# File 'lib/pray/trust.rb', line 186 def append_missing!(target, keys) added = 0 keys.each do |key| normalized = normalize_key(key) next if normalized.empty? next if target.any? { |existing| normalize_key(existing) == normalized } target << normalized added += 1 end added end |
.best_rule(policy, source_url) ⇒ Object
80 81 82 83 84 85 86 87 88 89 90 91 92 93 |
# File 'lib/pray/trust.rb', line 80 def best_rule(policy, source_url) selected = nil selected_length = 0 policy.rules.each do |rule| prefix = rule.match_prefix next if prefix.nil? || prefix.empty? next unless source_url.start_with?(prefix) next unless prefix.length > selected_length selected = rule selected_length = prefix.length end selected || policy.default_rule end |
.check_compromised(source = nil, home: trust_home) ⇒ Object
13 14 15 16 17 |
# File 'lib/pray/trust_feed.rb', line 13 def check_compromised(source = nil, home: trust_home) source_description, body = fetch_compromised_feed(source) entries = parse_compromised_feed(body, source_description) [source_description, compromised_hits(home, entries)] end |
.compromised_hits(home, entries) ⇒ Object
78 79 80 81 82 83 84 85 86 87 88 89 |
# File 'lib/pray/trust_feed.rb', line 78 def compromised_hits(home, entries) trusted = trusted_keys_by_scope(home) return [] if trusted.empty? by_key = entries.group_by(&:key) trusted.filter_map do |key, scopes| matches = by_key[key] next unless matches [key, scopes, matches] end end |
.fetch_compromised_feed(source) ⇒ Object
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
# File 'lib/pray/trust_feed.rb', line 27 def fetch_compromised_feed(source) url = source || DEFAULT_COMPROMISED_KEYS_SOURCE if url.start_with?("http://", "https://") uri = URI(url) response = Net::HTTP.start( uri.hostname, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 30 ) { |http| http.get(uri.request_uri) } unless response.is_a?(Net::HTTPSuccess) raise Error.resolution("HTTP request failed for #{url}: #{response.code}") end return [url, response.body] end path = File.(url) [path, File.read(path)] end |
.fetch_ssh_publisher_fingerprints(source_url) ⇒ Object
150 151 152 153 154 155 156 157 158 159 160 161 162 163 |
# File 'lib/pray/trust_ops.rb', line 150 def fetch_ssh_publisher_fingerprints(source_url) body = read_distribution_bytes(source_url, "v1/ssh_publishers.json") return nil unless body data = JSON.parse(body) Array(data["publishers"]).filter_map do |entry| fingerprint = entry["fingerprint"].to_s next if fingerprint.empty? normalize_key(fingerprint) end rescue JSON::ParserError => error raise Error.parse("ssh publishers", error.) end |
.fingerprint_matches?(allowed, candidate) ⇒ Boolean
108 109 110 |
# File 'lib/pray/trust.rb', line 108 def fingerprint_matches?(allowed, candidate) normalize_key(allowed) == candidate || candidate.end_with?(normalize_key(allowed)) end |
.format_keyed_list(name, values) ⇒ Object
180 181 182 183 184 |
# File 'lib/pray/trust.rb', line 180 def format_keyed_list(name, values) return " #{name}: []" if values.empty? [" #{name}:", *values.map { |value| " - #{value}" }].join("\n") end |
.format_policy(policy) ⇒ Object
142 143 144 145 146 147 148 149 150 151 152 |
# File 'lib/pray/trust.rb', line 142 def format_policy(policy) lines = ["[default]"] lines.concat(format_rule_lines(policy.default_rule)) policy.rules.each do |rule| lines << "" lines << "[[rules]]" lines << "match_prefix = #{rule.match_prefix.inspect}" if rule.match_prefix lines.concat(format_rule_lines(rule)) end lines.join("\n") end |
.format_rule_block(scope, rule) ⇒ Object
168 169 170 171 172 173 174 175 176 177 178 |
# File 'lib/pray/trust.rb', line 168 def format_rule_block(scope, rule) lines = [ scope, " allow: #{rule.allow}", " require_signed_commit: #{rule.require_signed_commit}", format_keyed_list("allowed_signing_keys", rule.allowed_signing_keys), format_keyed_list("allowed_host_keys", rule.allowed_host_keys), format_keyed_list("allowed_publishers", rule.allowed_publishers) ] "#{lines.join("\n")}\n" end |
.format_rule_lines(rule) ⇒ Object
154 155 156 157 158 159 160 161 162 |
# File 'lib/pray/trust.rb', line 154 def format_rule_lines(rule) [ "allow = #{rule.allow}", "require_signed_commit = #{rule.require_signed_commit}", "allowed_signing_keys = #{toml_array(rule.allowed_signing_keys)}", "allowed_host_keys = #{toml_array(rule.allowed_host_keys)}", "allowed_publishers = #{toml_array(rule.allowed_publishers)}" ] end |
.git_format(repository, format) ⇒ Object
190 191 192 193 194 195 196 197 198 |
# File 'lib/pray/trust_ops.rb', line 190 def git_format(repository, format) output = IO.popen( ["git", "-C", repository, "log", "-1", "--format=#{format}"], err: File::NULL, &:read ) return "" unless $?.success? output.to_s.strip end |
.import_registry(source_url, match_prefix: nil, include_host_key: false, home: trust_home) ⇒ Object
62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 |
# File 'lib/pray/trust_ops.rb', line 62 def import_registry(source_url, match_prefix: nil, include_host_key: false, home: trust_home) if source_url.start_with?("pray+ssh://", "ssh+pray://") raise Error.unsupported("pray_ssh registry import is not implemented yet in pray-cli Ruby") end raise Error.unsupported("--host-key requires pray_ssh sources") if include_host_key publishers = fetch_ssh_publisher_fingerprints(source_url) raise Error.unsupported("no v1/ssh_publishers.json found for #{source_url}") if publishers.nil? if publishers.empty? raise Error.unsupported( "v1/ssh_publishers.json for #{source_url} lists no publisher fingerprints" ) end prefix = match_prefix || source_url policy = load_policy_or_default(home) rule = mutable_rule_for_match_prefix!(policy, prefix) publishers_added = append_missing!(rule.allowed_publishers, publishers) save_policy(policy, home) {publishers_added: publishers_added, host_keys_added: 0} end |
.import_repo(source_url, repository, match_prefix: nil, home: trust_home) ⇒ Object
84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 |
# File 'lib/pray/trust_ops.rb', line 84 def import_repo(source_url, repository, match_prefix: nil, home: trust_home) keys = repository_signing_keys(repository) if keys.empty? raise Error.unsupported( "no commit signing key/fingerprint found for HEAD in #{repository}" ) end prefix = match_prefix || source_url policy = load_policy_or_default(home) rule = mutable_rule_for_match_prefix!(policy, prefix) added = append_missing!(rule.allowed_signing_keys, keys) save_policy(policy, home) added end |
.list_policy(scope:, source_url: nil, home: trust_home) ⇒ Object
9 10 11 12 |
# File 'lib/pray/trust_ops.rb', line 9 def list_policy(scope:, source_url: nil, home: trust_home) policy = load_policy_or_default(home) source_url ? list_policy_for_source(policy, scope, source_url) : list_policy_all(policy, scope) end |
.list_policy_all(policy, scope) ⇒ Object
100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 |
# File 'lib/pray/trust_ops.rb', line 100 def list_policy_all(policy, scope) lines = [] if %i[all global].include?(scope) lines << format_rule_block("scope: global", policy.default_rule).rstrip end if %i[all local].include?(scope) if policy.rules.empty? lines << "scope: local\n (no rules)" else policy.rules.sort_by { |rule| rule.match_prefix.to_s }.each do |rule| prefix = rule.match_prefix || "-" lines << format_rule_block("scope: local (#{prefix})", rule).rstrip end end end lines.join("\n\n") end |
.list_policy_for_source(policy, scope, source_url) ⇒ Object
118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 |
# File 'lib/pray/trust_ops.rb', line 118 def list_policy_for_source(policy, scope, source_url) lines = ["source: #{source_url}", ""] if %i[all global].include?(scope) lines << format_rule_block("scope: global", policy.default_rule).rstrip lines << "" end if %i[all local].include?(scope) matched = policy.rules.select do |rule| prefix = rule.match_prefix prefix && !prefix.empty? && source_url.start_with?(prefix) end matched.sort_by! { |rule| -(rule.match_prefix&.length || 0) } lines << if matched.empty? "scope: local\n (no matching rules)" else matched.map { |rule| format_rule_block("scope: local (#{rule.match_prefix})", rule).rstrip }.join("\n") end lines << "" end if scope == :all effective = best_rule(policy, source_url) lines << if effective.match_prefix "effective_scope: local (#{effective.match_prefix})" else "effective_scope: global" end end lines.join("\n").strip end |
.load_policy(home = trust_home) ⇒ Object
43 44 45 46 47 48 49 50 |
# File 'lib/pray/trust.rb', line 43 def load_policy(home = trust_home) path = trust_policy_path(home) return nil unless File.file?(path) parse_policy(TomlRB.load_file(path)) rescue TomlRB::ParseError => error raise Error.parse("trust policy", "#{path}: #{error.}") end |
.load_policy_or_default(home = trust_home) ⇒ Object
52 53 54 |
# File 'lib/pray/trust.rb', line 52 def load_policy_or_default(home = trust_home) load_policy(home) || TrustPolicy.new end |
.mutable_rule_for_match_prefix!(policy, match_prefix) ⇒ Object
95 96 97 98 99 100 101 102 |
# File 'lib/pray/trust.rb', line 95 def mutable_rule_for_match_prefix!(policy, match_prefix) existing = policy.rules.find { |rule| rule.match_prefix == match_prefix } return existing if existing rule = TrustRule.new(match_prefix: match_prefix) policy.rules << rule rule end |
.normalize_key(value) ⇒ Object
104 105 106 |
# File 'lib/pray/trust.rb', line 104 def normalize_key(value) value.strip.upcase end |
.parse_compromised_feed(body, source_hint) ⇒ Object
19 20 21 22 23 24 25 |
# File 'lib/pray/trust_feed.rb', line 19 def parse_compromised_feed(body, source_hint) if source_hint.downcase.end_with?(".txt") parse_compromised_txt(body) else parse_compromised_toml(body) end end |
.parse_compromised_toml(body) ⇒ Object
46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
# File 'lib/pray/trust_feed.rb', line 46 def parse_compromised_toml(body) data = TomlRB.parse(body) Array(data["keys"]).filter_map do |entry| key = normalize_key(entry["value"].to_s) next if key.empty? CompromisedKeyEntry.new( key: key, reason: entry["reason"], reference: entry["reference"], reported_at: entry["reported_at"] ) end rescue TomlRB::ParseError [] end |
.parse_compromised_txt(body) ⇒ Object
61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
# File 'lib/pray/trust_feed.rb', line 61 def parse_compromised_txt(body) body.each_line.filter_map do |raw_line| line = raw_line.strip next if line.empty? || line.start_with?("#") head, reason = line.split("#", 2) key = normalize_key(head.to_s.split(/\s+/).first.to_s) next if key.empty? CompromisedKeyEntry.new( key: key, reason: reason&.strip&.empty? ? nil : reason&.strip, reference: nil, reported_at: nil ) end end |
.parse_policy(data) ⇒ Object
62 63 64 65 66 67 |
# File 'lib/pray/trust.rb', line 62 def parse_policy(data) TrustPolicy.new( default_rule: parse_rule(data["default"] || {}), rules: Array(data["rules"]).map { |entry| parse_rule(entry) } ) end |
.parse_rule(data) ⇒ Object
69 70 71 72 73 74 75 76 77 78 |
# File 'lib/pray/trust.rb', line 69 def parse_rule(data) TrustRule.new( match_prefix: data["match_prefix"], allow: data.fetch("allow", true), require_signed_commit: data.fetch("require_signed_commit", false), allowed_signing_keys: Array(data["allowed_signing_keys"]), allowed_host_keys: Array(data["allowed_host_keys"]), allowed_publishers: Array(data["allowed_publishers"]) ) end |
.prepare_source_host_keys(sources) ⇒ Object
112 113 114 115 116 117 118 119 120 121 |
# File 'lib/pray/trust.rb', line 112 def prepare_source_host_keys(sources) policy = load_policy_or_default sources.each_with_object({}) do |source, host_keys| next unless source.kind == "pray_ssh" rule = best_rule(policy, source.url) fingerprint = rule.allowed_host_keys.first host_keys[source.name] = fingerprint if fingerprint end end |
.read_distribution_bytes(source_url, relative_path) ⇒ Object
165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 |
# File 'lib/pray/trust_ops.rb', line 165 def read_distribution_bytes(source_url, relative_path) if source_url.start_with?("http://", "https://") begin return Registry.send(:http_get, Registry.send(:join_url, source_url, relative_path)) rescue Error return nil end end root = source_url.delete_prefix("file://") path = File.join(root, relative_path) return nil unless File.file?(path) File.read(path) end |
.remove_allowed_signing_key(key, match_prefix: nil, home: trust_home) ⇒ Object
30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
# File 'lib/pray/trust_ops.rb', line 30 def remove_allowed_signing_key(key, match_prefix: nil, home: trust_home) normalized = normalize_key(key) raise Error.unsupported("signing key is empty") if normalized.empty? policy = load_policy_or_default(home) rule = match_prefix ? mutable_rule_for_match_prefix!(policy, match_prefix) : policy.default_rule before = rule.allowed_signing_keys.length rule.allowed_signing_keys.reject! { |existing| normalize_key(existing) == normalized } if rule.allowed_signing_keys.length == before raise Error.unsupported( "signing key not found in allowed_signing_keys for #{match_prefix || "<default>"}" ) end save_policy(policy, home) end |
.repository_signing_keys(repository) ⇒ Object
181 182 183 184 185 186 187 188 |
# File 'lib/pray/trust_ops.rb', line 181 def repository_signing_keys(repository) keys = [] key = git_format(repository, "%GK") keys << normalize_key(key) unless key.empty? fingerprint = git_format(repository, "%GF") keys << normalize_key(fingerprint) unless fingerprint.empty? keys.uniq end |
.save_policy(policy, home = trust_home) ⇒ Object
56 57 58 59 60 |
# File 'lib/pray/trust.rb', line 56 def save_policy(policy, home = trust_home) path = trust_policy_path(home) FileUtils.mkdir_p(File.dirname(path)) File.write(path, "#{format_policy(policy)}\n") end |
.set_allow(match_prefix, allow, home: trust_home) ⇒ Object
54 55 56 57 58 59 60 |
# File 'lib/pray/trust_ops.rb', line 54 def set_allow(match_prefix, allow, home: trust_home) raise Error.unsupported("match-prefix is empty") if match_prefix.to_s.strip.empty? policy = load_policy_or_default(home) mutable_rule_for_match_prefix!(policy, match_prefix).allow = allow save_policy(policy, home) end |
.set_require_signed_commit(match_prefix, enabled, home: trust_home) ⇒ Object
46 47 48 49 50 51 52 |
# File 'lib/pray/trust_ops.rb', line 46 def set_require_signed_commit(match_prefix, enabled, home: trust_home) raise Error.unsupported("match-prefix is empty") if match_prefix.to_s.strip.empty? policy = load_policy_or_default(home) mutable_rule_for_match_prefix!(policy, match_prefix).require_signed_commit = enabled save_policy(policy, home) end |
.show_policy_toml(home = trust_home) ⇒ Object
14 15 16 |
# File 'lib/pray/trust_ops.rb', line 14 def show_policy_toml(home = trust_home) format_policy(load_policy_or_default(home)) end |
.toml_array(values) ⇒ Object
164 165 166 |
# File 'lib/pray/trust.rb', line 164 def toml_array(values) "[#{values.map(&:inspect).join(", ")}]" end |
.trust_home ⇒ Object
30 31 32 33 34 35 36 37 |
# File 'lib/pray/trust.rb', line 30 def trust_home return ENV["PRAY_HOME"] if ENV["PRAY_HOME"] home = ENV["HOME"] raise Error.manifest("HOME is not set; set PRAY_HOME to configure trust policy") unless home File.join(home, ".pray") end |
.trust_policy_path(home = trust_home) ⇒ Object
39 40 41 |
# File 'lib/pray/trust.rb', line 39 def trust_policy_path(home = trust_home) File.join(home, "trust.toml") end |
.trusted_keys_by_scope(home) ⇒ Object
91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 |
# File 'lib/pray/trust_feed.rb', line 91 def trusted_keys_by_scope(home) policy = load_policy(home) return {} unless policy output = Hash.new { |hash, key| hash[key] = [] } policy.default_rule.allowed_signing_keys.each do |key| normalized = normalize_key(key) output[normalized] << "global/default" unless normalized.empty? end policy.rules.each do |rule| scope = "local:#{rule.match_prefix || "-"}" rule.allowed_signing_keys.each do |key| normalized = normalize_key(key) output[normalized] << scope unless normalized.empty? end end output end |
.verify_publisher_fingerprint!(source_url, selected) ⇒ Object
123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 |
# File 'lib/pray/trust.rb', line 123 def verify_publisher_fingerprint!(source_url, selected) return unless selected.signature return unless selected.signer_fingerprint policy = load_policy_or_default rule = best_rule(policy, source_url) return if rule.allowed_publishers.empty? normalized = normalize_key(selected.signer_fingerprint) trusted = rule.allowed_publishers.any? do |publisher| fingerprint_matches?(publisher, normalized) end return if trusted raise Error.integrity( "publisher fingerprint #{selected.signer_fingerprint} is not trusted for #{source_url}" ) end |