yamine 0.9.2 → 0.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 10912afade2197809c63bc0d6382d754eaa236f5996462a242554775f9e48c1e
4
- data.tar.gz: f2100f695bc378b3bf95ad2089907e614eea6deea10cc4f3029a98397439eea0
3
+ metadata.gz: db8822f4d49064d2cb61c2219c43c7049eafc7e44a2bb4b918981ef7a4bd8d11
4
+ data.tar.gz: 31c30cc196665056aab7f711c092003f775af0d684fc891adcf039da1603a96b
5
5
  SHA512:
6
- metadata.gz: abfaa410dd665fb70e0d280b0ba0142607a095fc94ea3c88c5ea213cf32dec415fd0fec6723770a3a346a2b5ce4042d0b23a78d7796bb50e280f11fad7dedb13
7
- data.tar.gz: ffef03352feb6c30328769b6e6e5c87705458b711cb0b5cc7a7c0b0b6b9061c06ece931766cbd4c2ec9cf09e397f2fdacaf2ee6fcb87c6e707e9163026457926
6
+ metadata.gz: 85e04f77e692d458765ef87714bb83dac175f387dec1bba92bb46e4ac405d292d693a0292187bf427b6fc56bbbbf1263635bdcbcbd3426295531235aab51dc67
7
+ data.tar.gz: 226f812a9accf7265fcae4f1ae74328bb6229ee9199af790e2d744085fed517f7445a6c501c94b6ec188d8f0575677f91753831b9ef502349b4bf2f873b5dbf3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,70 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.0] — 2026-09-10
4
+
5
+ The ask-local rename is finished: the two user-visible names it left
6
+ behind are now yamine's own, and both migrate existing machines.
7
+
8
+ ### Changed
9
+
10
+ - **The CA is named "Yamine CA"** (was "Ask Local CA", inherited from
11
+ the rename). The old name stays in `Certs::LEGACY_CA_COMMON_NAMES`, so
12
+ a machine carrying a pre-rename CA can still shed it. Because
13
+ `valid_pair?` requires the on-disk CA to carry the current name, the
14
+ first run after upgrading generates a new CA and re-trusts it — once,
15
+ automatically, as part of the normal trust path.
16
+ - **The launchd service is `dev.yamine`** (was `dev.ask.local`). The old
17
+ plist is booted out and deleted during install and uninstall: a label
18
+ is how launchctl addresses a service, so leaving the old one loaded
19
+ would put two root proxies in a race for port 443, the loser
20
+ crash-looping under `KeepAlive`.
21
+
22
+ ### Fixed
23
+
24
+ - `prune_stale` protects the CA a running proxy signs with, identified
25
+ by signature rather than by skipping the prune entirely. A proxy loads
26
+ its CA once at startup and mints host certs from that in-memory copy,
27
+ so it keeps signing with its boot-time CA whatever is on disk —
28
+ removing that certificate would break TLS for every live route until a
29
+ restart. Since superseded CAs share a name with the live one, only a
30
+ signature check can tell them apart.
31
+
32
+
33
+ ## [0.9.3] — 2026-09-10
34
+
35
+ ### Fixed
36
+
37
+ - **The trust store accumulated duplicate CAs, and cleanup deleted the
38
+ wrong one.** `trust_macos` never checked whether the certificate was
39
+ already trusted, so every `setup`/`trust` run added another copy, and
40
+ nothing ever removed the certificates left behind when a CA was
41
+ regenerated (missing, expiring, or renamed). One machine had **14
42
+ distinct trusted roots**, all named "Ask Local CA". Since a superseded
43
+ CA's private key stays on disk, a trusted root whose key is still
44
+ recoverable is a real liability. Trust is now idempotent, and
45
+ `prune_stale` removes trusted certificates under our CA names that are
46
+ not the CA currently on disk.
47
+ - Cleanup no longer operates by common name. `untrust` ran
48
+ `delete-certificate -c "Ask Local CA"` up to five times per keychain,
49
+ which deletes an ARBITRARY certificate with that name — possibly the
50
+ current CA, or one a sibling checkout still serves — and can leave the
51
+ target behind. Everything now identifies certificates by SHA-1
52
+ fingerprint, and pruning additionally verifies the subject before
53
+ deleting, so an unrelated trusted root can never be touched.
54
+ - `prune_stale` refuses to run while a proxy is serving: a live proxy
55
+ holds the CA it booted with in memory, so removing that certificate
56
+ would break TLS for every live route until a restart.
57
+
58
+ ### Changed
59
+
60
+ - The CA keeps the legacy "Ask Local CA" name. Renaming it would
61
+ invalidate every existing CA (`valid_pair?` requires the name) and
62
+ force a regeneration plus re-trust on every machine — a real migration
63
+ cost for a cosmetic gain, and the name was never the actual problem.
64
+ `Certs::CA_COMMON_NAMES` lists every name we have generated so a future
65
+ rename stays prunable.
66
+
67
+
3
68
  ## [0.9.2] — 2026-09-10
4
69
 
5
70
  ### Fixed
data/lib/yamine/certs.rb CHANGED
@@ -11,13 +11,25 @@ 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
- CA_COMMON_NAME = "Ask Local CA"
14
+ # The CA's name is user-visible in the trust store and is what a
15
+ # cleanup keys off, so it belongs to this gem alone. It was inherited
16
+ # from the ask-local rename and had said "Ask Local CA" ever since.
17
+ CA_COMMON_NAME = "Yamine CA"
18
+ # Every name we have ever generated a CA under. Stale certificates
19
+ # under these are prunable (Trust.prune_stale); anything else in the
20
+ # trust store is none of our business. The pre-rename name stays here
21
+ # so machines that trusted an ask-local CA can still shed it.
22
+ LEGACY_CA_COMMON_NAMES = ["Ask Local CA"].freeze
15
23
  CA_VALIDITY_DAYS = 3650
16
24
  HOST_VALIDITY_DAYS = 825
17
25
  CACHE_SIZE = 1024
18
26
 
19
27
  module_function
20
28
 
29
+ def ca_common_names
30
+ [CA_COMMON_NAME, *LEGACY_CA_COMMON_NAMES].uniq
31
+ end
32
+
21
33
  def state_dir
22
34
  ENV["YAMINE_STATE_DIR"] || File.join(home, ".yamine")
23
35
  end
@@ -4,6 +4,13 @@ module Yamine
4
4
  class CLI
5
5
  # System commands: proxy, service, hosts, trust, clean, doctor, kamal.
6
6
  module SystemCommand
7
+ # The launchd label: the service's identity, and what `launchctl
8
+ # enable/kickstart/bootout` address it by. Renamed from "dev.ask.local"
9
+ # (an ask-local leftover); the old label is cleared on install so the
10
+ # two never coexist and fight over port 443.
11
+ LAUNCHD_LABEL = "dev.yamine"
12
+ LEGACY_LAUNCHD_LABELS = %w[dev.ask.local].freeze
13
+
7
14
  module_function
8
15
 
9
16
  def doctor(ctx, args)
@@ -291,7 +298,7 @@ module Yamine
291
298
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
292
299
  <plist version="1.0">
293
300
  <dict>
294
- <key>Label</key><string>dev.ask.local</string>
301
+ <key>Label</key><string>#{LAUNCHD_LABEL}</string>
295
302
  <key>ProgramArguments</key>
296
303
  <array>
297
304
  <string>#{RbConfig.ruby}</string>
@@ -309,7 +316,8 @@ module Yamine
309
316
  </dict>
310
317
  </plist>
311
318
  PLIST
312
- path = File.join(dir, "dev.ask.local.plist")
319
+ remove_legacy_launchd(dir)
320
+ path = File.join(dir, "#{LAUNCHD_LABEL}.plist")
313
321
  File.write(path, plist)
314
322
  File.chmod(0o644, path)
315
323
  # launchd requires /Library/LaunchDaemons plists to be
@@ -354,14 +362,34 @@ module Yamine
354
362
  unless Command.run("launchctl", "bootstrap", "system", path)
355
363
  raise Error, "launchctl bootstrap failed — check the plist at #{path}"
356
364
  end
357
- Command.run("launchctl", "enable", "system/dev.ask.local")
358
- Command.run("launchctl", "kickstart", "-k", "system/dev.ask.local")
365
+ Command.run("launchctl", "enable", "system/#{LAUNCHD_LABEL}")
366
+ Command.run("launchctl", "kickstart", "-k", "system/#{LAUNCHD_LABEL}")
359
367
  end
360
368
 
361
369
  def launchctl_bootout(path)
362
370
  Command.run("launchctl", "bootout", "system", path)
363
371
  end
364
372
 
373
+ # The launchd label, and the pre-rename one that must be cleared.
374
+ # A label is the service's identity: installing the new one while
375
+ # the old plist is still loaded would leave TWO root proxies
376
+ # fighting over port 443 (whichever booted last wins, and the loser
377
+ # crash-loops under KeepAlive).
378
+ #
379
+ # Boot out and delete any plist from a previous label. Idempotent:
380
+ # bootout on a service that was never loaded prints an error and
381
+ # exits non-zero, which is expected and quiet.
382
+ def remove_legacy_launchd(dir = "/Library/LaunchDaemons")
383
+ LEGACY_LAUNCHD_LABELS.each do |label|
384
+ path = File.join(dir, "#{label}.plist")
385
+ next unless File.file?(path)
386
+
387
+ Command.run("launchctl", "bootout", "system", path, out: File::NULL, err: File::NULL)
388
+ FileUtils.rm_f(path)
389
+ puts " Removed legacy launchd service #{label}."
390
+ end
391
+ end
392
+
365
393
  # Pure unit-file builder (testable without root). Binds 80/443 at
366
394
  # boot; the proxy runs with the invoking user's state dir.
367
395
  def systemd_unit
@@ -406,9 +434,12 @@ module Yamine
406
434
  end
407
435
  case RUBY_PLATFORM
408
436
  when /darwin/
409
- path = "/Library/LaunchDaemons/dev.ask.local.plist"
410
- launchctl_bootout(path)
437
+ path = "/Library/LaunchDaemons/#{LAUNCHD_LABEL}.plist"
438
+ launchctl_bootout(path) if File.file?(path)
411
439
  FileUtils.rm_f(path)
440
+ # Also clear any pre-rename service, so uninstall leaves no root
441
+ # proxy behind on a machine upgraded from an older yamine.
442
+ remove_legacy_launchd
412
443
  puts "Removed root LaunchDaemon."
413
444
  when /linux/
414
445
  Command.run("systemctl", "disable", "--now", "yamine")
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,202 @@ 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 CA
36
+ # this project has generated shares a name with the others, 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
+ # Certificates in a keychain as { fingerprint:, subject:, cert: }.
48
+ # Parsed in ONE call; empty when the keychain (or `security`) is
49
+ # unavailable, which callers treat as "cannot tell".
50
+ #
51
+ # -a is required: without it `security find-certificate` returns a
52
+ # single certificate, so any scan silently sees one entry and every
53
+ # comparison against it is wrong.
54
+ def keychain_certs(keychain)
55
+ out, status = Command.capture2("security", "find-certificate", "-a", "-Z", "-p", keychain)
56
+ return [] unless status.success?
57
+
58
+ require "digest"
59
+ out.split("-----BEGIN CERTIFICATE-----").filter_map do |block|
60
+ next unless block.include?("-----END CERTIFICATE-----")
61
+
62
+ cert = begin
63
+ OpenSSL::X509::Certificate.new("-----BEGIN CERTIFICATE-----" + block)
64
+ rescue OpenSSL::OpenSSLError
65
+ next
66
+ end
67
+ { fingerprint: Digest::SHA1.hexdigest(cert.to_der).upcase,
68
+ subject: cert.subject.to_s,
69
+ cert: cert }
70
+ end
71
+ rescue SystemCallError
72
+ []
73
+ end
74
+
75
+ # Fingerprints of certificates in a keychain, optionally limited to
76
+ # one common name.
77
+ def keychain_fingerprints(keychain, common_name: nil)
78
+ certs = keychain_certs(keychain)
79
+ certs = certs.select { |c| c[:subject].include?(common_name) } if common_name
80
+ certs.map { |c| c[:fingerprint] }
81
+ end
82
+
83
+ def keychains
84
+ [login_keychain, "/Library/Keychains/System.keychain"]
85
+ end
86
+
87
+ # Remove trusted certificates that carry one of our CA names but are
88
+ # neither the CA on disk nor the one a live proxy is signing with.
89
+ #
90
+ # Regeneration is normal (the CA is rebuilt when it is missing,
91
+ # expiring, or renamed), and each rebuild produced a new certificate
92
+ # that was trusted and NEVER removed: one machine accumulated 14
93
+ # distinct trusted roots, all named "Ask Local CA". Apple's keychain
94
+ # keeps its own copy, so deleting the state dir does not untrust
95
+ # anything, and a trusted root whose superseded private key is still
96
+ # on disk (or in a backup) is a real liability.
97
+ #
98
+ # The running proxy's CA is protected by SIGNATURE, not by name:
99
+ # `server_context` loads the CA once at startup and the SNI callback
100
+ # mints host certs from that in-memory copy, so a live proxy keeps
101
+ # using its boot-time CA whatever is on disk. Deleting that
102
+ # certificate would break TLS for every live route until a restart,
103
+ # and since superseded CAs share a name with the live one, only a
104
+ # signature check can tell them apart.
105
+ def prune_stale(dir = Certs.state_dir, keychains: nil)
106
+ current = fingerprint_of(Certs.ca_paths(dir)[:cert])
107
+ return 0 unless current
108
+
109
+ names = Certs.ca_common_names
110
+ leaf = live_proxy_cert
111
+ (keychains || self.keychains).sum do |keychain|
112
+ keychain_certs(keychain).count do |entry|
113
+ next false if entry[:fingerprint] == current
114
+ next false unless names.any? { |n| entry[:subject].include?(n) }
115
+ next false if leaf && signed_by?(leaf, entry[:cert])
116
+
117
+ Command.capture2("security", "delete-certificate", "-Z", entry[:fingerprint], keychain)
118
+ true
119
+ end
120
+ end
121
+ rescue SystemCallError
122
+ 0
123
+ end
124
+
125
+ # True when `cert` is the issuer of `leaf` — i.e. that CA signed it.
126
+ # This is what identifies the live proxy's CA exactly.
127
+ def signed_by?(leaf, cert)
128
+ leaf.verify(cert.public_key)
129
+ rescue StandardError
130
+ false
131
+ end
132
+
133
+ # A certificate minted by the running proxy, whose issuer is the CA it
134
+ # is signing with, or nil when no proxy is serving.
135
+ def live_proxy_cert(store = nil)
136
+ return nil unless defined?(ProxyControl)
137
+
138
+ store ||= RouteStore.new(Certs.state_dir)
139
+ port = ProxyControl.serving_port(store)
140
+ return nil unless port
141
+
142
+ proxy_peer_cert(port)
143
+ rescue StandardError
144
+ nil
145
+ end
146
+
147
+ def proxy_peer_cert(port, host = "yamine-ca-probe.localhost")
148
+ sock = TCPSocket.new("127.0.0.1", port)
149
+ ctx = OpenSSL::SSL::SSLContext.new
150
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
151
+ ssl = OpenSSL::SSL::SSLSocket.new(sock, ctx)
152
+ ssl.hostname = host if ssl.respond_to?(:hostname=)
153
+ ssl.connect
154
+ ssl.peer_cert
155
+ ensure
156
+ begin
157
+ ssl&.close
158
+ rescue StandardError
159
+ nil
160
+ end
161
+ begin
162
+ sock&.close unless sock.nil? || sock.closed?
163
+ rescue StandardError
164
+ nil
165
+ end
166
+ end
167
+
168
+ # The common name of the CA a running proxy actually signs with,
169
+ # read from the issuer of a certificate it mints, or nil when no
170
+ # proxy is serving or the name cannot be determined.
171
+ def serving_ca_common_name(store = nil)
172
+ leaf = live_proxy_cert(store)
173
+ return nil unless leaf
174
+
175
+ leaf.issuer.to_s[/CN=([^\/,]+)/, 1]
176
+ end
177
+
178
+ def proxy_peer_cert(port, host = "yamine-ca-probe.localhost")
179
+ sock = TCPSocket.new("127.0.0.1", port)
180
+ ctx = OpenSSL::SSL::SSLContext.new
181
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
182
+ ssl = OpenSSL::SSL::SSLSocket.new(sock, ctx)
183
+ ssl.hostname = host if ssl.respond_to?(:hostname=)
184
+ ssl.connect
185
+ ssl.peer_cert
186
+ ensure
187
+ begin
188
+ ssl&.close
189
+ rescue StandardError
190
+ nil
191
+ end
192
+ begin
193
+ sock&.close unless sock.nil? || sock.closed?
194
+ rescue StandardError
195
+ nil
196
+ end
197
+ end
198
+
33
199
  def trust_macos(cert_path)
34
200
  if Process.uid.zero?
35
201
  # Running elevated (service install / root proxy): add to the
36
202
  # System keychain with the admin (-d) domain. Root can modify it
37
203
  # silently — no GUI authorization popup, and every user's
38
204
  # browsers trust the proxy.
205
+ keychain = "/Library/Keychains/System.keychain"
206
+ return if already_trusted?(cert_path, keychain)
207
+
39
208
  _out, status = Command.capture2("security", "add-trusted-cert",
40
- "-d", "-r", "trustRoot", "-k", "/Library/Keychains/System.keychain", cert_path)
209
+ "-d", "-r", "trustRoot", "-k", keychain, cert_path)
41
210
  raise CertError, "security add-trusted-cert (system) failed" unless status.success?
42
211
  else
43
212
  keychain = login_keychain
213
+ return if already_trusted?(cert_path, keychain)
214
+
44
215
  _out, status = Command.capture2("security", "add-trusted-cert",
45
216
  "-r", "trustRoot", "-k", keychain, cert_path)
46
217
  raise CertError, "security add-trusted-cert failed" unless status.success?
47
218
  end
48
219
  end
49
220
 
221
+ # Adding the same certificate twice is not harmless: it is how the
222
+ # trust store accumulated duplicates across repeated setup runs.
223
+ def already_trusted?(cert_path, keychain)
224
+ fp = fingerprint_of(cert_path)
225
+ return false unless fp
226
+
227
+ keychain_fingerprints(keychain, common_name: nil).include?(fp)
228
+ end
229
+
50
230
  def login_keychain
51
231
  out, status = Command.capture2("security", "default-keychain")
52
232
  if status.success? && (m = out.match(/"(.+)"/))
@@ -92,18 +272,21 @@ module Yamine
92
272
  return { removed: true } unless Certs.trusted?(dir)
93
273
 
94
274
  paths = Certs.ca_paths(dir)
275
+ fp = fingerprint_of(paths[:cert])
95
276
  errors = []
96
277
  case platform
97
278
  when :macos
98
279
  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
280
+ # Delete by FINGERPRINT, never by common name. Every yamine CA is
281
+ # named "Ask Local CA", so `delete-certificate -c` removes an
282
+ # arbitrary one of them — possibly the CURRENT CA, or one a
283
+ # sibling checkout still serves — and leaves the target behind.
284
+ # The old by-name loop here deleted up to five certificates per
285
+ # keychain for that reason.
286
+ keychains.each do |kc|
287
+ next unless fp && keychain_fingerprints(kc).include?(fp)
288
+
289
+ Command.capture2("security", "delete-certificate", "-Z", fp, kc)
107
290
  end
108
291
  when :linux
109
292
  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.2"
4
+ VERSION = "0.10.0"
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.2
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto