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
200 201 202 203 204 |
# File 'lib/everywhere/minisign.rb', line 200 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]
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 169 |
# File 'lib/everywhere/minisign.rb', line 138 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
171 172 173 174 |
# File 'lib/everywhere/minisign.rb', line 171 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
194 |
# File 'lib/everywhere/minisign.rb', line 194 def key_id_hex(key_id) = key_id.unpack1("Q<").to_s(16).upcase |
.keygen(passphrase: "") ⇒ Object
---- keygen --------------------------------------------------------------
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
# File 'lib/everywhere/minisign.rb', line 47 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
192 |
# File 'lib/everywhere/minisign.rb', line 192 def n_log2(max_n) = (1..62).find { |i| (1 << i) > max_n / 2 } || 63 |
.parse_public_key(content) ⇒ Object
---- internals -----------------------------------------------------------
117 118 119 120 |
# File 'lib/everywhere/minisign.rb', line 117 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
122 123 124 125 126 127 128 129 130 131 132 133 134 135 |
# File 'lib/everywhere/minisign.rb', line 122 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.
105 106 107 108 109 110 111 112 113 |
# File 'lib/everywhere/minisign.rb', line 105 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.
178 179 180 181 182 183 184 185 186 187 188 189 190 |
# File 'lib/everywhere/minisign.rb', line 178 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.
71 72 73 74 75 76 77 78 79 80 81 82 83 |
# File 'lib/everywhere/minisign.rb', line 71 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.
89 90 91 92 93 94 95 96 97 98 99 100 101 |
# File 'lib/everywhere/minisign.rb', line 89 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
196 197 198 |
# File 'lib/everywhere/minisign.rb', line 196 def xor_bytes(a, b) a.unpack("C*").zip(b.unpack("C*")).map { |x, y| x ^ y }.pack("C*") end |