yamine 0.9.1 → 0.9.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5059de08c17cccfe2d24ce5217a2c3c70fb5cc004a6eb528856eedf9b5389409
4
- data.tar.gz: 19da6abd533f3f80cbdfda8553427ed987e1ba18f83d24cf37934c906d03caef
3
+ metadata.gz: 02f91bf18a0ae8ed2817f8ab3cb85ee9ff71de3f2e43117dacf77d593c6e348b
4
+ data.tar.gz: 434f9c5cf274c8da2199e91b9c0596e887b6fcc0a6bac9cd7130cbf6ad146ee8
5
5
  SHA512:
6
- metadata.gz: 8a31f0124a322242398ef9e416fe46a7dd6ab13aac2265fb6ebc00743da14b82ef20ef30af8f3f018627bf6534779e074173a4795936c392de6b1df41c654a6a
7
- data.tar.gz: 922a0dd3ce552a6a76f931ae50385a5f98bf5d87b7fcee13e44367b209c193dad3be8a526e23585950251251a10e8cbc324375ba71d06291c2d12964441a48ac
6
+ metadata.gz: 9cb727ca21c6461c15ece8c74cfb25f10b18dd9b48e052567803975fb15de152f7ea88bcd149de781004fc0e0601dd3a6c5ee2048f148b72c475d00c4b950f23
7
+ data.tar.gz: 1f93e9e52fdbcf8087a3d72464ce5de9669db3c720f9ac5bb823f4b85bf71d64651b070c9b118650ab87fbf35e3f2c7d2f456e717f3b81feff435afab53891a0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.3] — 2026-09-10
4
+
5
+ ### Fixed
6
+
7
+ - **The trust store accumulated duplicate CAs, and cleanup deleted the
8
+ wrong one.** `trust_macos` never checked whether the certificate was
9
+ already trusted, so every `setup`/`trust` run added another copy, and
10
+ nothing ever removed the certificates left behind when a CA was
11
+ regenerated (missing, expiring, or renamed). One machine had **14
12
+ distinct trusted roots**, all named "Ask Local CA". Since a superseded
13
+ CA's private key stays on disk, a trusted root whose key is still
14
+ recoverable is a real liability. Trust is now idempotent, and
15
+ `prune_stale` removes trusted certificates under our CA names that are
16
+ not the CA currently on disk.
17
+ - Cleanup no longer operates by common name. `untrust` ran
18
+ `delete-certificate -c "Ask Local CA"` up to five times per keychain,
19
+ which deletes an ARBITRARY certificate with that name — possibly the
20
+ current CA, or one a sibling checkout still serves — and can leave the
21
+ target behind. Everything now identifies certificates by SHA-1
22
+ fingerprint, and pruning additionally verifies the subject before
23
+ deleting, so an unrelated trusted root can never be touched.
24
+ - `prune_stale` refuses to run while a proxy is serving: a live proxy
25
+ holds the CA it booted with in memory, so removing that certificate
26
+ would break TLS for every live route until a restart.
27
+
28
+ ### Changed
29
+
30
+ - The CA keeps the legacy "Ask Local CA" name. Renaming it would
31
+ invalidate every existing CA (`valid_pair?` requires the name) and
32
+ force a regeneration plus re-trust on every machine — a real migration
33
+ cost for a cosmetic gain, and the name was never the actual problem.
34
+ `Certs::CA_COMMON_NAMES` lists every name we have generated so a future
35
+ rename stays prunable.
36
+
37
+
38
+ ## [0.9.2] — 2026-09-10
39
+
40
+ ### Fixed
41
+
42
+ - **doctor's version check was blind to its own motivating case.** A
43
+ serving proxy that recorded no version predates the marker (added in
44
+ 0.9.0), which means it is definitely not the code you have installed —
45
+ but with `version == nil` the check stayed silent. So the exact service
46
+ it exists to catch, a root service left behind by an older gem, kept
47
+ serving old code with doctor reporting `[ok] proxy state: consistent`.
48
+ It now warns and points at `sudo yamine service install`. (A machine
49
+ with no proxy at all is still silent — no version and nothing serving
50
+ is not a problem.)
51
+
52
+
3
53
  ## [0.9.1] — 2026-09-10
4
54
 
5
55
  ### Fixed
data/lib/yamine/certs.rb CHANGED
@@ -11,13 +11,33 @@ module Yamine
11
11
  # through the SNI callback and held in an in-memory LRU
12
12
  # (puma-dev ssl.go does the same; portless caches on disk instead).
13
13
  module Certs
14
+ # The name is inherited from the rename (ask-local → yamine) and is
15
+ # kept deliberately: `valid_pair?` below requires the on-disk CA to
16
+ # carry this name, so changing it would mark every existing CA
17
+ # invalid, forcing a regeneration and a re-trust (elevation / GUI
18
+ # auth) on every machine — a real migration cost for a cosmetic gain.
19
+ #
20
+ # The name was ALSO never the actual problem: cleanup used to delete
21
+ # by common name, which is ambiguous when several CAs share it. That
22
+ # is fixed by operating on fingerprints (see Trust), so names no
23
+ # longer decide what gets removed.
24
+ #
25
+ # CA_COMMON_NAMES is the list of names we have ever generated. Add to
26
+ # it on any future rename so old certificates stay prunable —
27
+ # Trust.prune_stale only ever removes certificates whose subject is
28
+ # one of these.
14
29
  CA_COMMON_NAME = "Ask Local CA"
30
+ CA_COMMON_NAMES = [CA_COMMON_NAME].freeze
15
31
  CA_VALIDITY_DAYS = 3650
16
32
  HOST_VALIDITY_DAYS = 825
17
33
  CACHE_SIZE = 1024
18
34
 
19
35
  module_function
20
36
 
37
+ def ca_common_names
38
+ CA_COMMON_NAMES
39
+ end
40
+
21
41
  def state_dir
22
42
  ENV["YAMINE_STATE_DIR"] || File.join(home, ".yamine")
23
43
  end
data/lib/yamine/doctor.rb CHANGED
@@ -124,6 +124,15 @@ module Yamine
124
124
  if version && version != Yamine::VERSION
125
125
  problems << "the running proxy is v#{version}, this CLI is v#{Yamine::VERSION}" \
126
126
  " — re-register it: sudo yamine service install"
127
+ elsif version.nil? && serving
128
+ # A serving proxy that recorded no version predates the marker
129
+ # (added in 0.9.0), so it is definitely not the code we ship with
130
+ # now. Without this the version check was blind to exactly the
131
+ # service it exists to catch — an old root service keeps serving
132
+ # old code until it is re-registered.
133
+ problems << "the running proxy does not report a version (installed before " \
134
+ "v0.9.0), this CLI is v#{Yamine::VERSION}" \
135
+ " — re-register it: sudo yamine service install"
127
136
  end
128
137
 
129
138
  if problems.empty?
data/lib/yamine/trust.rb CHANGED
@@ -18,6 +18,7 @@ module Yamine
18
18
 
19
19
  def trust(dir = Certs.state_dir)
20
20
  paths = Certs.ensure_ca(dir)
21
+ prune_stale(dir)
21
22
  case platform
22
23
  when :macos then trust_macos(paths[:cert])
23
24
  when :linux then trust_linux(paths[:cert])
@@ -30,23 +31,140 @@ module Yamine
30
31
  { trusted: false, error: e.message }
31
32
  end
32
33
 
34
+ # The fingerprint `security -Z` prints: uppercase hex, no colons.
35
+ # Certificates are identified by THIS, never by common name — every
36
+ # CA yamine has ever generated is called "Ask Local CA", so a
37
+ # by-name lookup or delete cannot tell the current CA from a stale
38
+ # one and may remove the wrong certificate.
39
+ def fingerprint_of(cert_path)
40
+ require "digest"
41
+ cert = OpenSSL::X509::Certificate.new(File.read(cert_path))
42
+ Digest::SHA1.hexdigest(cert.to_der).upcase
43
+ rescue OpenSSL::OpenSSLError, SystemCallError
44
+ nil
45
+ end
46
+
47
+ # Fingerprints of certificates already in a keychain, optionally
48
+ # limited to one common name. Empty when the keychain (or `security`)
49
+ # is unavailable — callers treat that as "cannot tell".
50
+ def keychain_fingerprints(keychain, common_name: nil)
51
+ args = ["security", "find-certificate", "-a", "-Z"]
52
+ args += ["-c", common_name] if common_name
53
+ args << keychain
54
+ out, status = Command.capture2(*args)
55
+ return [] unless status.success?
56
+
57
+ out.scan(/SHA-1 hash:\s*([0-9A-Fa-f]+)/).flatten.map(&:upcase)
58
+ rescue SystemCallError
59
+ []
60
+ end
61
+
62
+ def keychains
63
+ [login_keychain, "/Library/Keychains/System.keychain"]
64
+ end
65
+
66
+ # Remove trusted certificates that carry one of our CA names but are
67
+ # not the CA currently on disk.
68
+ #
69
+ # Regeneration is normal (the CA is rebuilt when it is missing, when
70
+ # it is expiring, or when its name changes), and each rebuild
71
+ # produced a new certificate that was trusted and NEVER removed: one
72
+ # machine accumulated 14 distinct trusted roots, all named
73
+ # "Ask Local CA". Apple's keychain keeps its own copy, so deleting
74
+ # the state dir does not untrust anything, and a trusted root whose
75
+ # superseded private key is still on disk (or in a backup) is a real
76
+ # liability.
77
+ #
78
+ # Never runs while a proxy is serving. A running proxy holds the CA
79
+ # it booted with in memory, so removing that certificate from the
80
+ # trust store would break TLS for every live route until a restart —
81
+ # worse than the cruft. It is deferred to the next trust/setup with
82
+ # no proxy running, which is also when a superseded CA is genuinely
83
+ # idle.
84
+ def prune_stale(dir = Certs.state_dir, keychains: nil, force: false)
85
+ return 0 if !force && serving_proxy?
86
+
87
+ current = fingerprint_of(Certs.ca_paths(dir)[:cert])
88
+ return 0 unless current
89
+
90
+ names = Certs.ca_common_names
91
+ (keychains || self.keychains).sum do |keychain|
92
+ keychain_fingerprints(keychain)
93
+ .select { |fp| fp != current && our_cert?(keychain, fp, names) }
94
+ .count do |fp|
95
+ Command.capture2("security", "delete-certificate", "-Z", fp, keychain)
96
+ true
97
+ end
98
+ end
99
+ rescue SystemCallError
100
+ 0
101
+ end
102
+
103
+ def serving_proxy?
104
+ return false unless defined?(ProxyControl)
105
+
106
+ !ProxyControl.serving_port(RouteStore.new(Certs.state_dir)).nil?
107
+ rescue StandardError
108
+ false
109
+ end
110
+
111
+ # Is the certificate with this fingerprint one of ours? Decided by
112
+ # reading its subject, so pruning only ever removes certificates
113
+ # under a name we generated — never an unrelated trusted root.
114
+ #
115
+ # -a is required: without it `security` returns a single certificate,
116
+ # so the subject check silently answered "not ours" for every
117
+ # certificate but one.
118
+ def our_cert?(keychain, fingerprint, names)
119
+ out, status = Command.capture2("security", "find-certificate", "-a", "-Z", "-p", keychain)
120
+ return false unless status.success?
121
+
122
+ require "digest"
123
+ out.split("-----BEGIN CERTIFICATE-----").any? do |block|
124
+ next false unless block.include?("-----END CERTIFICATE-----")
125
+
126
+ cert = begin
127
+ OpenSSL::X509::Certificate.new("-----BEGIN CERTIFICATE-----" + block)
128
+ rescue OpenSSL::OpenSSLError
129
+ next false
130
+ end
131
+ next false unless Digest::SHA1.hexdigest(cert.to_der).upcase == fingerprint
132
+
133
+ names.any? { |n| cert.subject.to_s.include?(n) }
134
+ end
135
+ end
136
+
33
137
  def trust_macos(cert_path)
34
138
  if Process.uid.zero?
35
139
  # Running elevated (service install / root proxy): add to the
36
140
  # System keychain with the admin (-d) domain. Root can modify it
37
141
  # silently — no GUI authorization popup, and every user's
38
142
  # browsers trust the proxy.
143
+ keychain = "/Library/Keychains/System.keychain"
144
+ return if already_trusted?(cert_path, keychain)
145
+
39
146
  _out, status = Command.capture2("security", "add-trusted-cert",
40
- "-d", "-r", "trustRoot", "-k", "/Library/Keychains/System.keychain", cert_path)
147
+ "-d", "-r", "trustRoot", "-k", keychain, cert_path)
41
148
  raise CertError, "security add-trusted-cert (system) failed" unless status.success?
42
149
  else
43
150
  keychain = login_keychain
151
+ return if already_trusted?(cert_path, keychain)
152
+
44
153
  _out, status = Command.capture2("security", "add-trusted-cert",
45
154
  "-r", "trustRoot", "-k", keychain, cert_path)
46
155
  raise CertError, "security add-trusted-cert failed" unless status.success?
47
156
  end
48
157
  end
49
158
 
159
+ # Adding the same certificate twice is not harmless: it is how the
160
+ # trust store accumulated duplicates across repeated setup runs.
161
+ def already_trusted?(cert_path, keychain)
162
+ fp = fingerprint_of(cert_path)
163
+ return false unless fp
164
+
165
+ keychain_fingerprints(keychain, common_name: nil).include?(fp)
166
+ end
167
+
50
168
  def login_keychain
51
169
  out, status = Command.capture2("security", "default-keychain")
52
170
  if status.success? && (m = out.match(/"(.+)"/))
@@ -92,18 +210,21 @@ module Yamine
92
210
  return { removed: true } unless Certs.trusted?(dir)
93
211
 
94
212
  paths = Certs.ca_paths(dir)
213
+ fp = fingerprint_of(paths[:cert])
95
214
  errors = []
96
215
  case platform
97
216
  when :macos
98
217
  Command.capture2("security", "remove-trusted-cert", paths[:cert])
99
- # delete-certificate fails silently when no match remains; loop
100
- # to clear duplicate CN entries from each keychain.
101
- [login_keychain, "/Library/Keychains/System.keychain"].each do |kc|
102
- 5.times do
103
- Command.capture2("security", "delete-certificate", "-c", Certs::CA_COMMON_NAME, kc)
104
- end
105
- rescue SystemCallError
106
- nil
218
+ # Delete by FINGERPRINT, never by common name. Every yamine CA is
219
+ # named "Ask Local CA", so `delete-certificate -c` removes an
220
+ # arbitrary one of them — possibly the CURRENT CA, or one a
221
+ # sibling checkout still serves — and leaves the target behind.
222
+ # The old by-name loop here deleted up to five certificates per
223
+ # keychain for that reason.
224
+ keychains.each do |kc|
225
+ next unless fp && keychain_fingerprints(kc).include?(fp)
226
+
227
+ Command.capture2("security", "delete-certificate", "-Z", fp, kc)
107
228
  end
108
229
  when :linux
109
230
  dest_dir, update_cmd = linux_ca_config
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.9.1"
4
+ VERSION = "0.9.3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yamine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.1
4
+ version: 0.9.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto