yamine 0.9.3 → 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: 02f91bf18a0ae8ed2817f8ab3cb85ee9ff71de3f2e43117dacf77d593c6e348b
4
- data.tar.gz: 434f9c5cf274c8da2199e91b9c0596e887b6fcc0a6bac9cd7130cbf6ad146ee8
3
+ metadata.gz: db8822f4d49064d2cb61c2219c43c7049eafc7e44a2bb4b918981ef7a4bd8d11
4
+ data.tar.gz: 31c30cc196665056aab7f711c092003f775af0d684fc891adcf039da1603a96b
5
5
  SHA512:
6
- metadata.gz: 9cb727ca21c6461c15ece8c74cfb25f10b18dd9b48e052567803975fb15de152f7ea88bcd149de781004fc0e0601dd3a6c5ee2048f148b72c475d00c4b950f23
7
- data.tar.gz: 1f93e9e52fdbcf8087a3d72464ce5de9669db3c720f9ac5bb823f4b85bf71d64651b070c9b118650ab87fbf35e3f2c7d2f456e717f3b81feff435afab53891a0
6
+ metadata.gz: 85e04f77e692d458765ef87714bb83dac175f387dec1bba92bb46e4ac405d292d693a0292187bf427b6fc56bbbbf1263635bdcbcbd3426295531235aab51dc67
7
+ data.tar.gz: 226f812a9accf7265fcae4f1ae74328bb6229ee9199af790e2d744085fed517f7445a6c501c94b6ec188d8f0575677f91753831b9ef502349b4bf2f873b5dbf3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
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
+
3
33
  ## [0.9.3] — 2026-09-10
4
34
 
5
35
  ### Fixed
data/lib/yamine/certs.rb CHANGED
@@ -11,23 +11,15 @@ 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.
29
- CA_COMMON_NAME = "Ask Local CA"
30
- CA_COMMON_NAMES = [CA_COMMON_NAME].freeze
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
31
23
  CA_VALIDITY_DAYS = 3650
32
24
  HOST_VALIDITY_DAYS = 825
33
25
  CACHE_SIZE = 1024
@@ -35,7 +27,7 @@ module Yamine
35
27
  module_function
36
28
 
37
29
  def ca_common_names
38
- CA_COMMON_NAMES
30
+ [CA_COMMON_NAME, *LEGACY_CA_COMMON_NAMES].uniq
39
31
  end
40
32
 
41
33
  def state_dir
@@ -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
@@ -32,8 +32,8 @@ module Yamine
32
32
  end
33
33
 
34
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
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
37
  # by-name lookup or delete cannot tell the current CA from a stale
38
38
  # one and may remove the wrong certificate.
39
39
  def fingerprint_of(cert_path)
@@ -44,93 +44,155 @@ module Yamine
44
44
  nil
45
45
  end
46
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)
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)
55
56
  return [] unless status.success?
56
57
 
57
- out.scan(/SHA-1 hash:\s*([0-9A-Fa-f]+)/).flatten.map(&:upcase)
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
58
71
  rescue SystemCallError
59
72
  []
60
73
  end
61
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
+
62
83
  def keychains
63
84
  [login_keychain, "/Library/Keychains/System.keychain"]
64
85
  end
65
86
 
66
87
  # Remove trusted certificates that carry one of our CA names but are
67
- # not the CA currently on disk.
88
+ # neither the CA on disk nor the one a live proxy is signing with.
68
89
  #
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.
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.
77
97
  #
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
-
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)
87
106
  current = fingerprint_of(Certs.ca_paths(dir)[:cert])
88
107
  return 0 unless current
89
108
 
90
109
  names = Certs.ca_common_names
110
+ leaf = live_proxy_cert
91
111
  (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
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
98
120
  end
99
121
  rescue SystemCallError
100
122
  0
101
123
  end
102
124
 
103
- def serving_proxy?
104
- return false unless defined?(ProxyControl)
105
-
106
- !ProxyControl.serving_port(RouteStore.new(Certs.state_dir)).nil?
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)
107
129
  rescue StandardError
108
130
  false
109
131
  end
110
132
 
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?
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)
121
137
 
122
- require "digest"
123
- out.split("-----BEGIN CERTIFICATE-----").any? do |block|
124
- next false unless block.include?("-----END CERTIFICATE-----")
138
+ store ||= RouteStore.new(Certs.state_dir)
139
+ port = ProxyControl.serving_port(store)
140
+ return nil unless port
125
141
 
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
142
+ proxy_peer_cert(port)
143
+ rescue StandardError
144
+ nil
145
+ end
132
146
 
133
- names.any? { |n| cert.subject.to_s.include?(n) }
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
134
196
  end
135
197
  end
136
198
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.9.3"
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.3
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto