///|
/// Armor-header text name of a hash algorithm (RFC 9580 §9.5 names as
/// used by the `Hash:` armor header of cleartext messages).
fn hash_name(id : @primitives.HashId) -> String {
match id {
@primitives.Md5 => "MD5"
@primitives.Sha1 => "SHA1"
@primitives.Ripemd160 => "RIPEMD160"
@primitives.Sha224 => "SHA224"
@primitives.Sha256 => "SHA256"
@primitives.Sha384 => "SHA384"
@primitives.Sha512 => "SHA512"
}
}
///|
/// Canonical form used by cleartext signatures and text signatures:
/// CRLF line endings with trailing spaces/tabs removed per line
/// (RFC 9580 §7.1).
fn canonicalize_cleartext(text : String) -> Bytes {
let normalized = text.replace_all(old="\r\n", new="\n")
let lines : Array[StringView] = normalized.split("\n").collect()
let out : Array[Byte] = []
// Drop ALL trailing empty elements: the final line ending of the wire
// body is the separator before the armor (RFC 9580 §7.1) and is not
// part of the digest. Interior blank lines are preserved.
let mut end = lines.length()
while end > 0 && lines[end - 1].is_empty() {
end = end - 1
}
for i in 0.. 0 {
out.push(b'\r')
out.push(b'\n')
}
let line = trim_trailing_ws(lines[i])
let line_bytes = @utf8.encode(line)
for c in line_bytes {
out.push(c)
}
}
Bytes::from_array(out)
}
///|
fn trim_trailing_ws(line : StringView) -> StringView {
let mut end = line.length()
while end > 0 {
let c = line[end - 1]
if c == ' ' || c == '\t' {
end = end - 1
} else {
break
}
}
line[0:end]
}
///|
/// Dash-escape for display: lines starting with "-" (and, per RFC 9580
/// §7.2 SHOULD, "From ") get a "- " prefix.
fn dash_escape_display(canonical : Bytes) -> String {
let text = @utf8.decode_lossy(canonical.exact_view())
let normalized = text.replace_all(old="\r\n", new="\n")
let lines : Array[StringView] = normalized.split("\n").collect()
let buf = StringBuilder()
let n = lines.length()
for i in 0.. String raise {
sign_cleartext_with(text, key, @primitives.Sha256, created?)
}
///|
/// Create a cleartext signed message with an explicit hash algorithm.
pub fn sign_cleartext_with(
text : String,
key : PrivateKey,
hash : @primitives.HashId,
created? : Int32,
) -> String raise {
let canonical = canonicalize_cleartext(text)
let sig = make_text_signature(key, canonical, hash, created?)
let sig_armor = @armor.Signature.encode(
@packet.serialize_packet(@packet.Signature, sig.to_body().exact_view()).exact_view(),
)
let displayed = dash_escape_display(canonical)
let buf = StringBuilder()
buf.write_string("-----BEGIN PGP SIGNED MESSAGE-----\n")
buf.write_string("Hash: \{hash_name(hash)}\n\n")
buf.write_string(displayed)
if !displayed.has_suffix("\n") {
buf.write_char('\n')
}
buf.write_string(sig_armor)
buf.to_string()
}
///|
/// Verify a cleartext signed message. Returns the de-escaped, canonical
/// cleartext and the verification status against `verifier`.
pub fn verify_cleartext(
message : String,
verifier : PublicKey,
) -> (String, VerifyStatus) raise {
let lines : Array[StringView] = message.split("\n").collect()
let n = lines.length()
let mut i = 0
// Skip to the BEGIN marker.
while i < n && !lines[i].has_prefix("-----BEGIN PGP SIGNED MESSAGE-----") {
i = i + 1
}
guard i < n else { @error.fail("api: no cleartext signed message") }
i = i + 1
// Hash armor headers until the empty line; only Hash headers are legal.
// The values themselves are informational; the signature packet carries
// the authoritative hash algorithm.
while i < n && lines[i] != "" {
let line = lines[i]
let is_hash = line.has_prefix("Hash:") || line.has_prefix("hash:")
guard is_hash else {
@error.fail("api: illegal armor header in cleartext message")
}
// The Hash: header value is informational — the digest algorithms come
// from the signature packets themselves — so there is nothing to parse
// here (unknown names are tolerated, RFC 9580 §7.2).
i = i + 1
}
guard i < n else { @error.fail("api: truncated cleartext message") }
i = i + 1
// Dash-escaped cleartext until the signature armor begins. The line
// ending before the armor is not part of the signed text.
let body = StringBuilder()
while i < n && !lines[i].has_prefix("-----BEGIN PGP SIGNATURE-----") {
let line = lines[i]
if line.has_prefix("- ") {
body.write_stringview(line[2:line.length()])
} else {
// A bare "-" (not a dash-space escape) is treated as data; RFC 9580
// §7.2 says this SHOULD produce a warning.
body.write_stringview(line)
}
body.write_char('\n')
i = i + 1
}
guard i < n else { @error.fail("api: no signature in cleartext message") }
// Reassemble the canonical cleartext: the trailing "\n" written per
// line is exactly the text's own line ending (the wire separator was
// the armor begin line, excluded above).
let cleartext_raw = body.to_string()
let canonical = canonicalize_cleartext(cleartext_raw)
// The signature armor continues from here.
let rest = StringBuilder()
while i < n {
rest.write_stringview(lines[i])
rest.write_char('\n')
i = i + 1
}
let sig_block = @armor.decode(rest.to_string())
guard sig_block.armor_type() == @armor.Signature else {
@error.fail("api: cleartext message has no signature armor")
}
let packets = @packet.parse_packets(
@armor.ArmorBlock::data(sig_block).exact_view(),
)
let mut sig : @packet.SignaturePacket? = None
for p in packets {
if p.tag() == @packet.Signature {
sig = Some(@packet.SignaturePacket::parse(p.body().exact_view()))
break
}
}
match sig {
Some(s) => {
let status = verify_signature_packet(s, canonical, verifier)
// Lossy on purpose (S9): `canonical` is assembled from String
// pieces via @utf8.encode, which never emits invalid UTF-8 — decode
// cannot fail, and a silent "" fallback would desync the returned
// text from the verified bytes. Lossy makes that invariant explicit
// instead of trapping.
let text = @utf8.decode_lossy(canonical.exact_view())
(text, status)
}
None => @error.fail("api: no signature packet in cleartext message")
}
}