yamine 0.9.3 → 0.10.1

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: 73e497200efd0723559dff2f57e920e3d126d3caecf302770c37bec613d7998e
4
+ data.tar.gz: 2203cabc149df919703fb1182fb9ad76e9c6729d4ed105455c749dbfa7611593
5
5
  SHA512:
6
- metadata.gz: 9cb727ca21c6461c15ece8c74cfb25f10b18dd9b48e052567803975fb15de152f7ea88bcd149de781004fc0e0601dd3a6c5ee2048f148b72c475d00c4b950f23
7
- data.tar.gz: 1f93e9e52fdbcf8087a3d72464ce5de9669db3c720f9ac5bb823f4b85bf71d64651b070c9b118650ab87fbf35e3f2c7d2f456e717f3b81feff435afab53891a0
6
+ metadata.gz: d30e26aec61c7036e349a84f6f241291756c7ee3207f6e1de210cb47465ccc94279619cc9657d3ecbc477b8983be8027ae2303d43456b4891762f79dec7bd885
7
+ data.tar.gz: 1a6a7e1b4e402b822a3d6f70ed6b19881c8927563c131875b322a7b750c9ec9368ba8a7f45ff99badfe55d23c54a964f163a80b8d10d6c45aa6a5a3ae5d9abc9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.1] — 2026-09-10
4
+
5
+ ### Fixed
6
+
7
+ - **The CA is regenerated before trust is judged, not after.** Both
8
+ trust paths asked `Certs.trusted?` first, which compares the trust
9
+ marker against the certificate ON DISK. A proxy regenerates the CA at
10
+ boot when the on-disk one is missing, expiring, or renamed — so after
11
+ a rename the marker still matched the OLD certificate, trust was
12
+ skipped as "already done", and the proxy then came up serving a CA the
13
+ keychain did not trust: TLS verification fails for every route. The
14
+ check now brings the CA up to date first (`ca_current_and_trusted?`,
15
+ used by both paths).
16
+
17
+
18
+ ## [0.10.0] — 2026-09-10
19
+
20
+ The ask-local rename is finished: the two user-visible names it left
21
+ behind are now yamine's own, and both migrate existing machines.
22
+
23
+ ### Changed
24
+
25
+ - **The CA is named "Yamine CA"** (was "Ask Local CA", inherited from
26
+ the rename). The old name stays in `Certs::LEGACY_CA_COMMON_NAMES`, so
27
+ a machine carrying a pre-rename CA can still shed it. Because
28
+ `valid_pair?` requires the on-disk CA to carry the current name, the
29
+ first run after upgrading generates a new CA and re-trusts it — once,
30
+ automatically, as part of the normal trust path.
31
+ - **The launchd service is `dev.yamine`** (was `dev.ask.local`). The old
32
+ plist is booted out and deleted during install and uninstall: a label
33
+ is how launchctl addresses a service, so leaving the old one loaded
34
+ would put two root proxies in a race for port 443, the loser
35
+ crash-looping under `KeepAlive`.
36
+
37
+ ### Fixed
38
+
39
+ - `prune_stale` protects the CA a running proxy signs with, identified
40
+ by signature rather than by skipping the prune entirely. A proxy loads
41
+ its CA once at startup and mints host certs from that in-memory copy,
42
+ so it keeps signing with its boot-time CA whatever is on disk —
43
+ removing that certificate would break TLS for every live route until a
44
+ restart. Since superseded CAs share a name with the live one, only a
45
+ signature check can tell them apart.
46
+
47
+
3
48
  ## [0.9.3] — 2026-09-10
4
49
 
5
50
  ### 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
@@ -326,13 +334,29 @@ module Yamine
326
334
  puts "Installed root LaunchDaemon on port 443 (state: #{state_dir})."
327
335
  end
328
336
 
337
+ # True when the trust store covers the CA this machine should be
338
+ # serving with — regenerating the CA first if it is missing,
339
+ # expiring, or renamed.
340
+ #
341
+ # The ordering is the whole point, so it lives in one place rather
342
+ # than at each call site. `trusted?` compares a marker against the
343
+ # fingerprint of the certificate ON DISK, and a proxy regenerates
344
+ # the CA at boot when the name changes. Checking the marker before
345
+ # the CA is current therefore reads a stale match, skips trust, and
346
+ # lets the proxy come up serving a CA the keychain does not trust —
347
+ # breaking TLS for every route.
348
+ def ca_current_and_trusted?(dir = Certs.state_dir)
349
+ Certs.ensure_ca(dir)
350
+ Certs.trusted?(dir)
351
+ end
352
+
329
353
  # Root-only CA trust: the System keychain (all users, no prompt).
330
354
  # Safe to call repeatedly — once the marker is set it is a no-op.
331
355
  # Prints before doing work: the System keychain add can take a few
332
356
  # seconds, and this runs in the root half of setup where silence
333
357
  # reads as a hang.
334
358
  def ensure_system_ca_trust
335
- return if Certs.trusted?(Certs.state_dir)
359
+ return if ca_current_and_trusted?(Certs.state_dir)
336
360
 
337
361
  puts " Trusting the CA into the System keychain..."
338
362
  result = Trust.trust
@@ -354,14 +378,34 @@ module Yamine
354
378
  unless Command.run("launchctl", "bootstrap", "system", path)
355
379
  raise Error, "launchctl bootstrap failed — check the plist at #{path}"
356
380
  end
357
- Command.run("launchctl", "enable", "system/dev.ask.local")
358
- Command.run("launchctl", "kickstart", "-k", "system/dev.ask.local")
381
+ Command.run("launchctl", "enable", "system/#{LAUNCHD_LABEL}")
382
+ Command.run("launchctl", "kickstart", "-k", "system/#{LAUNCHD_LABEL}")
359
383
  end
360
384
 
361
385
  def launchctl_bootout(path)
362
386
  Command.run("launchctl", "bootout", "system", path)
363
387
  end
364
388
 
389
+ # The launchd label, and the pre-rename one that must be cleared.
390
+ # A label is the service's identity: installing the new one while
391
+ # the old plist is still loaded would leave TWO root proxies
392
+ # fighting over port 443 (whichever booted last wins, and the loser
393
+ # crash-loops under KeepAlive).
394
+ #
395
+ # Boot out and delete any plist from a previous label. Idempotent:
396
+ # bootout on a service that was never loaded prints an error and
397
+ # exits non-zero, which is expected and quiet.
398
+ def remove_legacy_launchd(dir = "/Library/LaunchDaemons")
399
+ LEGACY_LAUNCHD_LABELS.each do |label|
400
+ path = File.join(dir, "#{label}.plist")
401
+ next unless File.file?(path)
402
+
403
+ Command.run("launchctl", "bootout", "system", path, out: File::NULL, err: File::NULL)
404
+ FileUtils.rm_f(path)
405
+ puts " Removed legacy launchd service #{label}."
406
+ end
407
+ end
408
+
365
409
  # Pure unit-file builder (testable without root). Binds 80/443 at
366
410
  # boot; the proxy runs with the invoking user's state dir.
367
411
  def systemd_unit
@@ -406,9 +450,12 @@ module Yamine
406
450
  end
407
451
  case RUBY_PLATFORM
408
452
  when /darwin/
409
- path = "/Library/LaunchDaemons/dev.ask.local.plist"
410
- launchctl_bootout(path)
453
+ path = "/Library/LaunchDaemons/#{LAUNCHD_LABEL}.plist"
454
+ launchctl_bootout(path) if File.file?(path)
411
455
  FileUtils.rm_f(path)
456
+ # Also clear any pre-rename service, so uninstall leaves no root
457
+ # proxy behind on a machine upgraded from an older yamine.
458
+ remove_legacy_launchd
412
459
  puts "Removed root LaunchDaemon."
413
460
  when /linux/
414
461
  Command.run("systemctl", "disable", "--now", "yamine")
@@ -729,7 +776,7 @@ module Yamine
729
776
  # those users had already chosen to avoid.
730
777
  def ensure_workstation!(ctx)
731
778
  # 1. CA
732
- unless Yamine::Certs.trusted?(ctx.store.dir)
779
+ unless ca_current_and_trusted?(ctx.store.dir)
733
780
  result = Yamine::Trust.trust
734
781
  unless result[:trusted]
735
782
  abort_setup("CA trust failed: #{result[:error]}",
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.1"
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto