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

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.expand_path(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.message)
end

.fingerprint_matches?(allowed, candidate) ⇒ Boolean

Returns:

  • (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.message}")
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_homeObject



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