Module: Everywhere::Minisign
- Defined in:
- lib/everywhere/minisign.rb
Overview
Minisign-compatible Ed25519 signing, stdlib-only (OpenSSL + our Blake2b).
This is the update-artifact signing scheme: every updates keygen mints a
keypair, every publish signs each artifact, and the shell verifies with
the minisign-verify Rust crate before installing an update. Files are
byte-compatible with the reference minisign tool — keys made here sign with
minisign -S, keys made there publish here, and every .minisig verifies
with minisign -V.
Formats (https://jedisct1.github.io/minisign/):
public key "Ed" || key_id[8] || ed25519_pk[32], base64, comment line above
secret key "Ed" || "Sc" || "B2" || salt[32] || opslimit[8 LE] ||
memlimit[8 LE] || (key_id[8] || sk[64] || checksum[32]) XOR
scrypt(passphrase); checksum = BLAKE2b-256("Ed"||key_id||sk)
signature "ED" || key_id[8] || sig[64] over BLAKE2b-512(file) —
prehashed mode, so signing never loads the artifact whole —
plus a global sig over sig||trusted_comment
Defined Under Namespace
Classes: KeyPair
Constant Summary collapse
- Error =
Class.new(StandardError)
- VerifyError =
Class.new(Error)
- PassphraseError =
Class.new(Error)
- UNTRUSTED =
"signature from ruby_everywhere secret key"- KDF_OPSLIMIT =
libsodium scryptsalsa208sha256 "interactive" cost (N=2^14, r=8, p=1 — 16 MB, milliseconds to derive). The reference tool defaults to "sensitive" (1 GB); both read whatever the key file declares, so the cheaper default costs no compatibility.
524_288- KDF_MEMLIMIT =
16_777_216
Class Method Summary collapse
- .decode64(str, what) ⇒ Object
-
.decrypt_secret_key(content, passphrase) ⇒ Object
-> [key_id, ed25519_seed].
- .kdf_stream(passphrase, salt, opslimit, memlimit) ⇒ Object
- .key_id_hex(key_id) ⇒ Object
-
.keygen(passphrase: "") ⇒ Object
---- keygen --------------------------------------------------------------.
- .n_log2(max_n) ⇒ Object
-
.parse_public_key(content) ⇒ Object
---- internals -----------------------------------------------------------.
- .parse_signature(content) ⇒ Object
-
.public_key_base64(content) ⇒ Object
The bare base64 line of a public key — what everywhere.yml stores and the shell's minisign-verify consumes.
-
.scrypt_params(opslimit, memlimit) ⇒ Object
Port of libsodium's pickparams: how opslimit/memlimit (what the key file stores) map to scrypt's N/r/p.
-
.sign_file(path, secret_key:, passphrase: "", trusted_comment: nil) ⇒ Object
Returns the full .minisig content for
path. -
.verify_file(path, signature:, public_key:) ⇒ Object
public_keymay be the two-line key file or just its base64 line. - .xor_bytes(a, b) ⇒ Object
Class Method Details
.decode64(str, what) ⇒ Object
199 200 201 202 203 |
# File 'lib/everywhere/minisign.rb', line 199 def decode64(str, what) Base64.strict_decode64(str.strip) rescue ArgumentError raise Error, "invalid base64 in #{what}" end |
.decrypt_secret_key(content, passphrase) ⇒ Object
-> [key_id, ed25519_seed]
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 |
# File 'lib/everywhere/minisign.rb', line 137 def decrypt_secret_key(content, passphrase) raise Error, "no secret key material given" if content.to_s.strip.empty? line = content.lines.map(&:strip).reject { |l| l.empty? || l.start_with?("untrusted comment:") }.first blob = decode64(line.to_s, "secret key") kdf_alg = blob.byteslice(2, 2) unless blob.bytesize == 158 && blob.start_with?("Ed") && ["Sc", "\x00\x00"].include?(kdf_alg) && blob.byteslice(4, 2) == "B2" raise Error, "unsupported secret key format" end keynum = if kdf_alg == "Sc" salt = blob.byteslice(6, 32) opslimit, memlimit = blob.byteslice(38, 16).unpack("Q<Q<") xor_bytes(blob.byteslice(54, 104), kdf_stream(passphrase, salt, opslimit, memlimit)) else blob.byteslice(54, 104) # null KDF (minisign -W): stored in the clear end key_id = keynum.byteslice(0, 8) sk = keynum.byteslice(8, 64) checksum = keynum.byteslice(72, 32) # Reference minisign writes an all-zero checksum for unencrypted (-W) # keys and skips the check; a wrong checksum on an encrypted key means a # wrong passphrase (scrypt stream decrypted to garbage). unless (kdf_alg != "Sc" && checksum == ("\0" * 32).b) || OpenSSL.secure_compare(checksum, Blake2b.digest("Ed#{key_id}#{sk}", 32)) raise PassphraseError, "wrong passphrase (or corrupt secret key)" end [key_id, sk.byteslice(0, 32)] end |
.kdf_stream(passphrase, salt, opslimit, memlimit) ⇒ Object
170 171 172 173 |
# File 'lib/everywhere/minisign.rb', line 170 def kdf_stream(passphrase, salt, opslimit, memlimit) n, r, p = scrypt_params(opslimit, memlimit) OpenSSL::KDF.scrypt(passphrase.to_s, salt: salt, N: n, r: r, p: p, length: 104) end |
.key_id_hex(key_id) ⇒ Object
193 |
# File 'lib/everywhere/minisign.rb', line 193 def key_id_hex(key_id) = key_id.unpack1("Q<").to_s(16).upcase |
.keygen(passphrase: "") ⇒ Object
---- keygen --------------------------------------------------------------
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
# File 'lib/everywhere/minisign.rb', line 46 def keygen(passphrase: "") pkey = OpenSSL::PKey.generate_key("ED25519") key_id = SecureRandom.bytes(8) sk = pkey.raw_private_key + pkey.raw_public_key # 32-byte seed + 32-byte pk checksum = Blake2b.digest("Ed#{key_id}#{sk}", 32) salt = SecureRandom.bytes(32) stream = kdf_stream(passphrase, salt, KDF_OPSLIMIT, KDF_MEMLIMIT) secret_blob = "EdScB2#{salt}#{[KDF_OPSLIMIT, KDF_MEMLIMIT].pack("Q<Q<")}" \ "#{xor_bytes("#{key_id}#{sk}#{checksum}", stream)}" public_blob = "Ed#{key_id}#{pkey.raw_public_key}" id_hex = key_id_hex(key_id) KeyPair.new( public_key: "untrusted comment: minisign public key #{id_hex}\n#{Base64.strict_encode64(public_blob)}\n", secret_key: "untrusted comment: minisign encrypted secret key\n#{Base64.strict_encode64(secret_blob)}\n", key_id_hex: id_hex ) end |
.n_log2(max_n) ⇒ Object
191 |
# File 'lib/everywhere/minisign.rb', line 191 def n_log2(max_n) = (1..62).find { |i| (1 << i) > max_n / 2 } || 63 |
.parse_public_key(content) ⇒ Object
---- internals -----------------------------------------------------------
116 117 118 119 |
# File 'lib/everywhere/minisign.rb', line 116 def parse_public_key(content) blob = decode64(public_key_base64(content), "public key") [blob.byteslice(2, 8), blob.byteslice(10, 32)] end |
.parse_signature(content) ⇒ Object
121 122 123 124 125 126 127 128 129 130 131 132 133 134 |
# File 'lib/everywhere/minisign.rb', line 121 def parse_signature(content) lines = content.lines.map(&:chomp) sig_b64 = lines.find { |l| !l.start_with?("untrusted comment:") && !l.strip.empty? } trusted = lines.find { |l| l.start_with?("trusted comment: ") }&.delete_prefix("trusted comment: ") global_b64 = lines[lines.index { |l| l.start_with?("trusted comment:") }.to_i + 1] if trusted raise VerifyError, "malformed signature file" unless sig_b64 && trusted && global_b64 blob = decode64(sig_b64, "signature") alg = blob.byteslice(0, 2) raise VerifyError, "unsupported signature algorithm #{alg.inspect}" unless %w[ED Ed].include?(alg) raise VerifyError, "malformed signature" unless blob.bytesize == 74 [alg, blob.byteslice(2, 8), blob.byteslice(10, 64), trusted, decode64(global_b64, "global signature")] end |
.public_key_base64(content) ⇒ Object
The bare base64 line of a public key — what everywhere.yml stores and the shell's minisign-verify consumes.
104 105 106 107 108 109 110 111 112 |
# File 'lib/everywhere/minisign.rb', line 104 def public_key_base64(content) line = content.lines.map(&:strip).reject { |l| l.empty? || l.start_with?("untrusted comment:") }.first raise Error, "no public key data found" unless line blob = decode64(line, "public key") raise Error, "unsupported public key format" unless blob.bytesize == 42 && blob.start_with?("Ed") line end |
.scrypt_params(opslimit, memlimit) ⇒ Object
Port of libsodium's pickparams: how opslimit/memlimit (what the key file stores) map to scrypt's N/r/p.
177 178 179 180 181 182 183 184 185 186 187 188 189 |
# File 'lib/everywhere/minisign.rb', line 177 def scrypt_params(opslimit, memlimit) opslimit = 32_768 if opslimit < 32_768 r = 8 if opslimit < memlimit / 32 max_n = opslimit / (r * 4) [1 << n_log2(max_n), r, 1] else max_n = memlimit / (r * 128) n = 1 << n_log2(max_n) max_rp = [(opslimit / 4) / n, 0x3fffffff].min [n, r, [max_rp / r, 1].max] end end |
.sign_file(path, secret_key:, passphrase: "", trusted_comment: nil) ⇒ Object
Returns the full .minisig content for path.
70 71 72 73 74 75 76 77 78 79 80 81 82 |
# File 'lib/everywhere/minisign.rb', line 70 def sign_file(path, secret_key:, passphrase: "", trusted_comment: nil) key_id, seed = decrypt_secret_key(secret_key, passphrase) pkey = OpenSSL::PKey.new_raw_private_key("ED25519", seed) signature = pkey.sign(nil, Blake2b.digest64_file(path)) trusted_comment ||= "timestamp:#{Time.now.to_i}\tfile:#{File.basename(path)}\thashed" global = pkey.sign(nil, signature + trusted_comment.b) "untrusted comment: #{UNTRUSTED}\n" \ "#{Base64.strict_encode64("ED#{key_id}#{signature}")}\n" \ "trusted comment: #{trusted_comment}\n" \ "#{Base64.strict_encode64(global)}\n" end |
.verify_file(path, signature:, public_key:) ⇒ Object
public_key may be the two-line key file or just its base64 line.
Raises VerifyError unless everything checks out.
88 89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/everywhere/minisign.rb', line 88 def verify_file(path, signature:, public_key:) alg, key_id, sig, trusted_comment, global = parse_signature(signature) pk_key_id, pk = parse_public_key(public_key) raise VerifyError, "signature key id #{key_id_hex(key_id)} doesn't match public key #{key_id_hex(pk_key_id)}" unless key_id == pk_key_id pkey = OpenSSL::PKey.new_raw_public_key("ED25519", pk) = alg == "ED" ? Blake2b.digest64_file(path) : File.binread(path) raise VerifyError, "file signature invalid for #{File.basename(path)}" unless pkey.verify(nil, sig, ) raise VerifyError, "trusted comment signature invalid" unless pkey.verify(nil, global, sig + trusted_comment.b) true end |
.xor_bytes(a, b) ⇒ Object
195 196 197 |
# File 'lib/everywhere/minisign.rb', line 195 def xor_bytes(a, b) a.unpack("C*").zip(b.unpack("C*")).map { |x, y| x ^ y }.pack("C*") end |