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 +4 -4
- data/CHANGELOG.md +65 -0
- data/lib/yamine/certs.rb +13 -1
- data/lib/yamine/cli/system.rb +37 -6
- data/lib/yamine/trust.rb +192 -9
- data/lib/yamine/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: db8822f4d49064d2cb61c2219c43c7049eafc7e44a2bb4b918981ef7a4bd8d11
|
|
4
|
+
data.tar.gz: 31c30cc196665056aab7f711c092003f775af0d684fc891adcf039da1603a96b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
data/lib/yamine/cli/system.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
358
|
-
Command.run("launchctl", "kickstart", "-k", "system
|
|
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
|
|
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",
|
|
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
|
-
#
|
|
100
|
-
#
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
data/lib/yamine/version.rb
CHANGED