///|
/// moonpgp — OpenPGP mail encryption demo CLI.
///
/// moon run cmd/main -- help
/// moon run cmd/main -- keygen "Name " key.asc pub.asc [bits]
/// moon run cmd/main -- encpass
/// moon run cmd/main -- decpass
/// moon run cmd/main -- encpub
/// moon run cmd/main -- decpriv [passphrase]
/// moon run cmd/main -- sign
/// moon run cmd/main -- verify
/// moon run cmd/main -- lock
/// moon run cmd/main -- unlock
fn main {
let args = @env.args()
if args.length() < 2 {
usage()
}
// `help`, `--help` and `-h` print the usage text and exit successfully.
if args[1] == "help" || args[1] == "--help" || args[1] == "-h" {
print_usage()
return
}
let rest : Array[String] = args[2:args.length()].to_owned()
run(args[1], rest) catch {
e => {
println("error: \{e}")
abort("command failed")
}
}
}
///|
fn run(command : String, rest : Array[String]) -> Unit raise {
match command {
"keygen" => {
check_args(rest.length(), 3)
let key = @api.generate_key(rest[0], bits=parse_bits(rest, 2048))
@fs.write_string_to_file(rest[1], key.to_armored())
@fs.write_string_to_file(rest[2], key.to_public().to_armored())
println("wrote \{rest[1]} (secret) and \{rest[2]} (public)")
}
"edkeygen" => {
check_args(rest.length(), 3)
let key = @api.generate_ed25519_key(rest[0])
@fs.write_string_to_file(rest[1], key.to_armored())
@fs.write_string_to_file(rest[2], key.to_public().to_armored())
println("wrote \{rest[1]} (secret) and \{rest[2]} (public)")
}
"v6keygen" => {
check_args(rest.length(), 3)
let key = @api.generate_ed25519_v6_key(rest[0])
@fs.write_string_to_file(rest[1], key.to_armored())
@fs.write_string_to_file(rest[2], key.to_public().to_armored())
println("wrote \{rest[1]} (secret) and \{rest[2]} (public)")
}
"encpass" => {
check_args(rest.length(), 3)
let data = @fs.read_file_to_bytes(rest[1])
// Stamp the literal packet with the input's file name so the
// backup-file scenario keeps a trace of what was encrypted.
let file_name = basename(rest[1])
// Optional trailing "aead" selects the v5 SKESK + tag-20 shape
// (GnuPG --force-aead interop) instead of v4 SKESK + SEIPD v1.
let aead = rest.length() >= 4 && rest[3] == "aead"
let armored = @api.encrypt_with_password(data, rest[0], aead~, file_name~)
@fs.write_string_to_file(rest[2], armored)
println("wrote \{rest[2]}")
}
"decpass" => {
check_args(rest.length(), 3)
let armored = @fs.read_file_to_string(rest[1])
let plain = @api.decrypt_with_password(armored, rest[0])
@fs.write_bytes_to_file(rest[2], plain)
println("wrote \{rest[2]}")
}
"encpub" => {
check_args(rest.length(), 3)
let key = @api.PublicKey::from_armored(@fs.read_file_to_string(rest[0]))
let data = @fs.read_file_to_bytes(rest[1])
let armored = @api.encrypt_to_recipients(data, [key])
@fs.write_string_to_file(rest[2], armored)
println("wrote \{rest[2]}")
}
"decpriv" => {
check_args(rest.length(), 3)
let key_text = @fs.read_file_to_string(rest[0])
let key = @api.PrivateKey::from_armored(key_text)
// Snapshot the lock state BEFORE unlocking: unlock clears s2k_usage,
// so key.is_locked() is false afterwards and must not drive the
// argument positions (a locked key needs [key, passphrase, in, out]).
let was_locked = key.is_locked()
let key = if was_locked { key.unlock(rest[1]) } else { key }
if was_locked {
check_args(rest.length(), 4)
}
let input = if was_locked { rest[2] } else { rest[1] }
let output = if was_locked { rest[3] } else { rest[2] }
let data = @api.decrypt_with_private_key(
@fs.read_file_to_string(input),
key,
)
@fs.write_bytes_to_file(output, data)
println("wrote \{output}")
}
"sign" => {
check_args(rest.length(), 3)
let key = @api.PrivateKey::from_armored(@fs.read_file_to_string(rest[0]))
let data = @fs.read_file_to_bytes(rest[1])
let sig = @api.sign_detached(data.exact_view(), key)
@fs.write_string_to_file(rest[2], sig)
println("wrote \{rest[2]}")
}
"verify" => {
check_args(rest.length(), 3)
let key = @api.PublicKey::from_armored(@fs.read_file_to_string(rest[0]))
let data = @fs.read_file_to_bytes(rest[1])
let sig = @fs.read_file_to_string(rest[2])
match @api.verify_detached(data.exact_view(), sig, key) {
@api.Ok => println("Good signature")
@api.NotSigned => {
println("No signature found")
abort("verification failed")
}
@api.NoVerifier => {
println("No verifier for this signature")
abort("verification failed")
}
@api.Failed => {
println("Bad signature")
abort("verification failed")
}
@api.KeyRevoked => {
println("Signing key is revoked")
abort("verification failed")
}
@api.Expired => {
println("Signing key or signature is expired")
abort("verification failed")
}
}
}
"lock" => {
check_args(rest.length(), 2)
let key = @api.PrivateKey::from_armored(@fs.read_file_to_string(rest[0]))
let locked = key.lock(rest[1])
@fs.write_string_to_file(rest[0], locked.to_armored())
println(
"locked \{rest[0]} (decryption and signing now require the passphrase)",
)
}
"unlock" => {
check_args(rest.length(), 2)
let key = @api.PrivateKey::from_armored(@fs.read_file_to_string(rest[0]))
let open = key.unlock(rest[1])
@fs.write_string_to_file(rest[0], open.to_armored())
println(
"unlocked \{rest[0]} (the secret key is now stored without passphrase protection)",
)
}
_ => usage()
}
}
///|
fn check_args(got : Int32, want : Int32) -> Unit {
if got < want {
usage()
}
}
///|
/// The final path component, stamped into encrypted literal packets.
fn basename(path : String) -> String {
let mut last = -1
for i, ch in path {
if ch == '/' {
last = i
}
}
if last < 0 {
path
} else {
path[last + 1:path.length()].to_owned()
}
}
///|
fn parse_bits(rest : Array[String], default : Int32) -> Int32 {
match rest[3:rest.length()].to_owned() {
[b, ..] => @string.parse_int(b) catch { _ => default }
_ => default
}
}
///|
fn usage() -> Unit {
print_usage()
abort("missing arguments")
}
///|
fn print_usage() -> Unit {
println(
"usage:\n" +
" help print this message\n" +
" keygen \"Name \" key.asc pub.asc [bits]\n" +
" edkeygen \"Name \" key.asc pub.asc\n" +
" v6keygen \"Name \" key.asc pub.asc (RFC 9580 v6 Ed25519)\n" +
" encpass [aead] (aead = tag-20/GnuPG shape)\n" +
" decpass \n" +
" encpub \n" +
" decpriv [passphrase] \n" +
" sign \n" +
" verify \n" +
" lock (protect the stored secret key)\n" +
" unlock (strip passphrase protection)",
)
}