yamine 0.9.2 → 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 +4 -4
- data/CHANGELOG.md +35 -0
- data/lib/yamine/certs.rb +20 -0
- data/lib/yamine/trust.rb +130 -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: 02f91bf18a0ae8ed2817f8ab3cb85ee9ff71de3f2e43117dacf77d593c6e348b
|
|
4
|
+
data.tar.gz: 434f9c5cf274c8da2199e91b9c0596e887b6fcc0a6bac9cd7130cbf6ad146ee8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9cb727ca21c6461c15ece8c74cfb25f10b18dd9b48e052567803975fb15de152f7ea88bcd149de781004fc0e0601dd3a6c5ee2048f148b72c475d00c4b950f23
|
|
7
|
+
data.tar.gz: 1f93e9e52fdbcf8087a3d72464ce5de9669db3c720f9ac5bb823f4b85bf71d64651b070c9b118650ab87fbf35e3f2c7d2f456e717f3b81feff435afab53891a0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
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
|
+
|
|
3
38
|
## [0.9.2] — 2026-09-10
|
|
4
39
|
|
|
5
40
|
### 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/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",
|
|
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
|
-
#
|
|
100
|
-
#
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
data/lib/yamine/version.rb
CHANGED