mirror of
https://github.com/Quad4-Software/Reticulum-Go
synced 2026-08-29 23:48:44 -04:00
565 lines
15 KiB
Go
565 lines
15 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright (c) 2024-2026 Quad4.io
|
|
|
|
package cli
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"quad4/reticulum-go/pkg/identity"
|
|
"quad4/reticulum-go/pkg/identity/store"
|
|
"quad4/reticulum-go/pkg/rnsutil"
|
|
"quad4/reticulum-go/pkg/securemem"
|
|
)
|
|
|
|
const defaultAspects = "rns.id"
|
|
|
|
func RunID(args []string, opt ...Options) int {
|
|
stdout, stderr := cliIO(opt)
|
|
fs := flag.NewFlagSet("rgoid", flag.ContinueOnError)
|
|
fs.SetOutput(stderr)
|
|
|
|
identityPath := fs.String("i", "", "path to identity file or hex identity hash")
|
|
generate := fs.String("g", "", "generate identity and write to path")
|
|
importPub := fs.String("m", "", "import public key (hex/base64/base32)")
|
|
importPrv := fs.String("M", "", "import private key (hex/base64/base32)")
|
|
exportPub := fs.Bool("x", false, "export public key")
|
|
exportPrv := fs.Bool("X", false, "export private key")
|
|
printID := fs.Bool("p", false, "print identity hash and keys summary")
|
|
printPriv := fs.Bool("P", false, "allow printing private key material")
|
|
hashAspects := fs.String("H", "", "print destination hash for dotted aspects name")
|
|
signPath := fs.String("s", "", "sign file to .rsg")
|
|
signMsg := fs.String("S", "", "create embedded signed message (.rsm). use @path to read message from file")
|
|
verifyPath := fs.String("V", "", "validate .rsg/.rsm or file+signature")
|
|
encryptPath := fs.String("e", "", "encrypt file to .rfe")
|
|
decryptPath := fs.String("d", "", "decrypt .rfe file")
|
|
rawSign := fs.Bool("raw", false, "write legacy raw Ed25519 signature only")
|
|
showMeta := fs.Bool("meta", false, "display RSM metadata when validating")
|
|
extractMsg := fs.Bool("extract", false, "on successful RSM verify, print only the embedded message")
|
|
writeOut := fs.String("w", "", "write output to path")
|
|
force := fs.Bool("f", false, "overwrite existing output")
|
|
useB64 := fs.Bool("b", false, "base64 encoding")
|
|
useB32 := fs.Bool("B", false, "base32 encoding")
|
|
useHex := fs.Bool("hex", false, "hex encoding (default)")
|
|
toSecretService := fs.Bool("to-secretservice", false, "migrate identity file into Freedesktop Secret Service (writes RSSI marker)")
|
|
toKeyring := fs.Bool("to-keyring", false, "migrate identity file into Linux kernel keyring (writes RSSI marker)")
|
|
toFile := fs.Bool("to-file", false, "migrate marker-backed identity back to a plaintext identity file")
|
|
|
|
if err := fs.Parse(args); err != nil {
|
|
return 2
|
|
}
|
|
|
|
migrateCount := 0
|
|
if *toSecretService {
|
|
migrateCount++
|
|
}
|
|
if *toKeyring {
|
|
migrateCount++
|
|
}
|
|
if *toFile {
|
|
migrateCount++
|
|
}
|
|
if migrateCount > 0 {
|
|
path := expand(*identityPath)
|
|
if path == "" {
|
|
fmt.Fprintln(stderr, "migrate requires -i path")
|
|
return 2
|
|
}
|
|
if migrateCount > 1 {
|
|
fmt.Fprintln(stderr, "use only one of -to-secretservice, -to-keyring, or -to-file")
|
|
return 2
|
|
}
|
|
var err error
|
|
switch {
|
|
case *toSecretService:
|
|
err = store.MigrateToSecretService(path, "")
|
|
case *toKeyring:
|
|
err = store.MigrateToKeyring(path, "")
|
|
default:
|
|
err = store.MigrateToFile(path)
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "%v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Fprintln(stdout, okMsg(stdout, "Identity storage migrated"))
|
|
return 0
|
|
}
|
|
|
|
enc, err := pickEncoding(*useB64, *useB32, *useHex)
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err)
|
|
return 2
|
|
}
|
|
|
|
ident, err := resolveIdentity(*identityPath, *generate, *importPub, *importPrv, enc)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "%v\n", err)
|
|
return 1
|
|
}
|
|
|
|
didWork := false
|
|
if *generate != "" {
|
|
didWork = true
|
|
fmt.Fprintln(stdout, okMsg(stdout, fmt.Sprintf("Generated identity %s", ident.GetHexHash())))
|
|
}
|
|
if *printID {
|
|
if ident == nil {
|
|
fmt.Fprintln(stderr, "no identity")
|
|
return 1
|
|
}
|
|
didWork = true
|
|
printIdentity(ident, *printPriv, enc)
|
|
}
|
|
if *exportPub {
|
|
if ident == nil {
|
|
fmt.Fprintln(stderr, "no identity")
|
|
return 1
|
|
}
|
|
didWork = true
|
|
out := rnsutil.EncodeBytes(ident.GetPublicKey(), enc) + "\n"
|
|
if err := writeOutput(*writeOut, []byte(out), *force); err != nil {
|
|
fmt.Fprintf(stderr, "%v\n", err)
|
|
return 1
|
|
}
|
|
}
|
|
if *exportPrv {
|
|
if ident == nil {
|
|
fmt.Fprintln(stderr, "no identity")
|
|
return 1
|
|
}
|
|
didWork = true
|
|
priv, err := ident.GetPrivateKey()
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "%v\n", err)
|
|
return 1
|
|
}
|
|
out := rnsutil.EncodeBytes(priv, enc) + "\n"
|
|
securemem.WipeBytes(priv)
|
|
if err := writeOutput(*writeOut, []byte(out), *force); err != nil {
|
|
fmt.Fprintf(stderr, "%v\n", err)
|
|
return 1
|
|
}
|
|
}
|
|
if *hashAspects != "" {
|
|
if ident == nil {
|
|
fmt.Fprintln(stderr, "hash requires an identity")
|
|
return 1
|
|
}
|
|
didWork = true
|
|
name := *hashAspects
|
|
if name == "" {
|
|
name = defaultAspects
|
|
}
|
|
h, err := rnsutil.DestinationHashHex(ident, name)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "%v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Fprintln(stdout, h)
|
|
}
|
|
if *signPath != "" {
|
|
if ident == nil {
|
|
fmt.Fprintln(stderr, "signing requires an identity")
|
|
return 1
|
|
}
|
|
didWork = true
|
|
if code := doSign(ident, *signPath, *writeOut, *force, *rawSign); code != 0 {
|
|
return code
|
|
}
|
|
}
|
|
if *signMsg != "" {
|
|
if ident == nil {
|
|
fmt.Fprintln(stderr, "signing requires an identity")
|
|
return 1
|
|
}
|
|
didWork = true
|
|
msg, err := resolveSignMessage(*signMsg)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "%v\n", err)
|
|
return 1
|
|
}
|
|
if code := doSignMessage(ident, msg, *writeOut, *force); code != 0 {
|
|
return code
|
|
}
|
|
}
|
|
if *verifyPath != "" {
|
|
didWork = true
|
|
if code := doVerify(ident, *identityPath, *verifyPath, *showMeta, *extractMsg); code != 0 {
|
|
return code
|
|
}
|
|
}
|
|
if *encryptPath != "" {
|
|
if ident == nil {
|
|
fmt.Fprintln(stderr, "encrypt requires an identity")
|
|
return 1
|
|
}
|
|
didWork = true
|
|
if code := doEncrypt(ident, *encryptPath, *writeOut, *force); code != 0 {
|
|
return code
|
|
}
|
|
}
|
|
if *decryptPath != "" {
|
|
if ident == nil {
|
|
fmt.Fprintln(stderr, "decrypt requires an identity")
|
|
return 1
|
|
}
|
|
didWork = true
|
|
if code := doDecrypt(ident, *decryptPath, *writeOut, *force); code != 0 {
|
|
return code
|
|
}
|
|
}
|
|
|
|
if !didWork {
|
|
fs.Usage()
|
|
return 2
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func readInputBytes(path string) ([]byte, error) {
|
|
if path == "-" {
|
|
return io.ReadAll(os.Stdin)
|
|
}
|
|
return os.ReadFile(path) // #nosec G304
|
|
}
|
|
|
|
func doSign(ident *identity.Identity, signPath, writeOut string, force, raw bool) int {
|
|
signPath = expand(signPath)
|
|
outPath := writeOut
|
|
if outPath == "" {
|
|
outPath = signPath + "." + rnsutil.SigExt
|
|
}
|
|
if !force {
|
|
if _, err := os.Stat(outPath); err == nil {
|
|
fmt.Fprintf(os.Stderr, "refusing to overwrite %s (use -f)\n", outPath)
|
|
return 11
|
|
}
|
|
}
|
|
var data []byte
|
|
var err error
|
|
if raw {
|
|
payload, err := readInputBytes(signPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
return 6
|
|
}
|
|
data, err = ident.Sign(payload)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
return 254
|
|
}
|
|
} else {
|
|
data, err = rnsutil.SignFileRSG(ident, signPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
return 254
|
|
}
|
|
}
|
|
if err := rnsutil.WriteFileAtomic(outPath, data); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
return 253
|
|
}
|
|
fmt.Fprintln(os.Stdout, okMsg(os.Stdout, fmt.Sprintf("Signed file %s with %s", signPath, ident.GetHexHash())))
|
|
return 0
|
|
}
|
|
|
|
func resolveSignMessage(signMsg string) (string, error) {
|
|
if strings.HasPrefix(signMsg, "@") {
|
|
path := expand(signMsg[1:])
|
|
b, err := os.ReadFile(path) // #nosec G304 -- operator-chosen message path
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(b), nil
|
|
}
|
|
return signMsg, nil
|
|
}
|
|
|
|
func doSignMessage(ident *identity.Identity, message, writeOut string, force bool) int {
|
|
if writeOut == "" {
|
|
fmt.Fprintln(os.Stderr, "signed message requires -w path")
|
|
return 250
|
|
}
|
|
outPath := expand(writeOut)
|
|
if !strings.HasSuffix(strings.ToLower(outPath), "."+rnsutil.MsgExt) {
|
|
outPath += "." + rnsutil.MsgExt
|
|
}
|
|
if !force {
|
|
if _, err := os.Stat(outPath); err == nil {
|
|
fmt.Fprintf(os.Stderr, "refusing to overwrite %s (use -f)\n", outPath)
|
|
return 11
|
|
}
|
|
}
|
|
rsm, err := rnsutil.CreateRSM(ident, message, nil)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
return 254
|
|
}
|
|
if err := rnsutil.WriteFileAtomic(outPath, rsm); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
return 253
|
|
}
|
|
fmt.Fprintln(os.Stdout, okMsg(os.Stdout, fmt.Sprintf("Message signed with %s saved to %s", ident.GetHexHash(), outPath)))
|
|
return 0
|
|
}
|
|
|
|
func doVerify(ident *identity.Identity, identityArg, verifyPath string, showMeta, extractMsg bool) int {
|
|
verifyPath = expand(verifyPath)
|
|
lower := strings.ToLower(verifyPath)
|
|
switch {
|
|
case strings.HasSuffix(lower, "."+rnsutil.MsgExt):
|
|
rsm, err := os.ReadFile(verifyPath) // #nosec G304
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
return 6
|
|
}
|
|
var required any
|
|
if ident != nil {
|
|
required = ident
|
|
} else if len(identityArg) == 32 {
|
|
required = identityArg
|
|
}
|
|
res, text, err := rnsutil.VerifyRSM(rsm, required)
|
|
if err != nil || !res.Valid {
|
|
fmt.Fprintln(os.Stderr, errMsg(os.Stderr, fmt.Sprintf("Invalid signature in %s", verifyPath)))
|
|
return 10
|
|
}
|
|
if extractMsg {
|
|
_, err := os.Stdout.WriteString(text)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
return 253
|
|
}
|
|
if !strings.HasSuffix(text, "\n") {
|
|
fmt.Fprintln(os.Stdout)
|
|
}
|
|
return 0
|
|
}
|
|
if showMeta && res.Envelope != nil && res.Envelope.Meta != nil {
|
|
fmt.Fprintln(os.Stdout, infoMsg(os.Stdout, "RSM Metadata"))
|
|
for k, v := range res.Envelope.Meta {
|
|
fmt.Fprintf(os.Stdout, " %s=%v\n", k, v)
|
|
}
|
|
}
|
|
signer := res.Signer.GetHexHash()
|
|
fmt.Fprintf(os.Stdout, "%s, the message was signed by %s\n\n%s\n", okMsg(os.Stdout, "Signature is valid"), signer, text)
|
|
return 0
|
|
case strings.HasSuffix(lower, "."+rnsutil.SigExt):
|
|
filePath := verifyPath[:len(verifyPath)-len(rnsutil.SigExt)-1]
|
|
rsg, err := os.ReadFile(verifyPath) // #nosec G304
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
return 6
|
|
}
|
|
var required any
|
|
if ident != nil {
|
|
required = ident
|
|
}
|
|
res, err := rnsutil.VerifyFileRSG(rsg, filePath, required)
|
|
if err != nil || !res.Valid {
|
|
fmt.Fprintln(os.Stderr, errMsg(os.Stderr, fmt.Sprintf("Invalid signature %s for file %s", verifyPath, filePath)))
|
|
return 10
|
|
}
|
|
fmt.Fprintf(os.Stdout, "%s, the file %s was signed by %s\n", okMsg(os.Stdout, "Signature is valid"), filePath, res.Signer.GetHexHash())
|
|
return 0
|
|
default:
|
|
rsgPath := verifyPath + "." + rnsutil.SigExt
|
|
rsg, err := os.ReadFile(rsgPath) // #nosec G304
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "No signature file exists for %q\n", verifyPath)
|
|
return 6
|
|
}
|
|
var required any
|
|
if ident != nil {
|
|
required = ident
|
|
}
|
|
res, err := rnsutil.VerifyFileRSG(rsg, verifyPath, required)
|
|
if err != nil || !res.Valid {
|
|
fmt.Fprintln(os.Stderr, errMsg(os.Stderr, fmt.Sprintf("Invalid signature %s for file %s", rsgPath, verifyPath)))
|
|
return 10
|
|
}
|
|
fmt.Fprintf(os.Stdout, "%s, the file %s was signed by %s\n", okMsg(os.Stdout, "Signature is valid"), verifyPath, res.Signer.GetHexHash())
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func doEncrypt(ident *identity.Identity, encryptPath, writeOut string, force bool) int {
|
|
encryptPath = expand(encryptPath)
|
|
outPath := writeOut
|
|
if outPath == "" {
|
|
outPath = encryptPath + "." + rnsutil.EncryptExt
|
|
} else {
|
|
outPath = expand(outPath)
|
|
}
|
|
if !force {
|
|
if _, err := os.Stat(outPath); err == nil {
|
|
fmt.Fprintf(os.Stderr, "refusing to overwrite %s (use -f)\n", outPath)
|
|
return 11
|
|
}
|
|
}
|
|
if err := rnsutil.EncryptFileRFE(ident, encryptPath, outPath); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
return 254
|
|
}
|
|
fmt.Fprintln(os.Stdout, okMsg(os.Stdout, fmt.Sprintf("File %s encrypted for %s to %s", encryptPath, ident.GetHexHash(), outPath)))
|
|
return 0
|
|
}
|
|
|
|
func doDecrypt(ident *identity.Identity, decryptPath, writeOut string, force bool) int {
|
|
decryptPath = expand(decryptPath)
|
|
if !strings.HasSuffix(strings.ToLower(decryptPath), "."+rnsutil.EncryptExt) {
|
|
fmt.Fprintf(os.Stderr, "The file %s does not appear to be a Reticulum encrypted file\n", decryptPath)
|
|
return 7
|
|
}
|
|
outPath := writeOut
|
|
if outPath == "" {
|
|
outPath = decryptPath[:len(decryptPath)-len(rnsutil.EncryptExt)-1]
|
|
} else {
|
|
outPath = expand(outPath)
|
|
}
|
|
if !force {
|
|
if _, err := os.Stat(outPath); err == nil {
|
|
fmt.Fprintf(os.Stderr, "refusing to overwrite %s (use -f)\n", outPath)
|
|
return 11
|
|
}
|
|
}
|
|
if err := rnsutil.DecryptFileRFE(ident, decryptPath, outPath); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
return 12
|
|
}
|
|
fmt.Fprintln(os.Stdout, okMsg(os.Stdout, fmt.Sprintf("File %s decrypted to %s", decryptPath, outPath)))
|
|
return 0
|
|
}
|
|
|
|
func pickEncoding(b64, b32, hexFlag bool) (rnsutil.Encoding, error) {
|
|
n := 0
|
|
enc := rnsutil.EncHex
|
|
if b64 {
|
|
enc = rnsutil.EncBase64
|
|
n++
|
|
}
|
|
if b32 {
|
|
enc = rnsutil.EncBase32
|
|
n++
|
|
}
|
|
if hexFlag {
|
|
enc = rnsutil.EncHex
|
|
n++
|
|
}
|
|
if n > 1 {
|
|
return enc, fmt.Errorf("encoding flags -b, -B, and -hex are mutually exclusive")
|
|
}
|
|
return enc, nil
|
|
}
|
|
|
|
func resolveIdentity(path, generate, importPub, importPrv string, enc rnsutil.Encoding) (*identity.Identity, error) {
|
|
n := 0
|
|
if path != "" {
|
|
n++
|
|
}
|
|
if generate != "" {
|
|
n++
|
|
}
|
|
if importPub != "" {
|
|
n++
|
|
}
|
|
if importPrv != "" {
|
|
n++
|
|
}
|
|
if n > 1 {
|
|
return nil, fmt.Errorf("-i, -g, -m and -M are mutually exclusive")
|
|
}
|
|
switch {
|
|
case generate != "":
|
|
return rnsutil.GenerateIdentity(expand(generate))
|
|
case importPub != "":
|
|
if st, err := os.Stat(expand(importPub)); err == nil && !st.IsDir() {
|
|
b, err := os.ReadFile(expand(importPub)) // #nosec G304
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(b) == 64 {
|
|
id := identity.FromPublicKey(b)
|
|
if id == nil {
|
|
return nil, fmt.Errorf("invalid public key file")
|
|
}
|
|
return id, nil
|
|
}
|
|
return rnsutil.ImportPublicIdentity(string(bytesTrim(b)), enc)
|
|
}
|
|
return rnsutil.ImportPublicIdentity(importPub, enc)
|
|
case importPrv != "":
|
|
if st, err := os.Stat(expand(importPrv)); err == nil && !st.IsDir() {
|
|
return rnsutil.LoadIdentity(expand(importPrv))
|
|
}
|
|
return rnsutil.ImportPrivateIdentity(importPrv, enc)
|
|
case path != "":
|
|
p := expand(path)
|
|
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
|
return rnsutil.LoadIdentity(p)
|
|
}
|
|
// Allow a bare identity hash for verify-only (no private key on disk).
|
|
if len(path) == 32 {
|
|
for _, c := range path {
|
|
if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
|
|
return nil, fmt.Errorf("identity file not found: %s", p)
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("identity file not found: %s", p)
|
|
default:
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
func bytesTrim(b []byte) []byte {
|
|
return []byte(strings.TrimSpace(string(b)))
|
|
}
|
|
|
|
func printIdentity(id *identity.Identity, showPriv bool, enc rnsutil.Encoding) {
|
|
fmt.Fprintf(os.Stdout, "%s : %s\n", infoMsg(os.Stdout, "Identity hash"), id.GetHexHash())
|
|
fmt.Fprintf(os.Stdout, "%s : %s\n", infoMsg(os.Stdout, "Public key "), rnsutil.EncodeBytes(id.GetPublicKey(), enc))
|
|
if !showPriv {
|
|
return
|
|
}
|
|
priv, err := id.GetPrivateKey()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stdout, "%s : unavailable (%v)\n", warnMsg(os.Stdout, "Private key "), err)
|
|
return
|
|
}
|
|
fmt.Fprintf(os.Stdout, "%s : %s\n", infoMsg(os.Stdout, "Private key "), rnsutil.EncodeBytes(priv, enc))
|
|
}
|
|
|
|
func writeOutput(path string, data []byte, force bool) error {
|
|
if path == "" {
|
|
_, err := os.Stdout.Write(data)
|
|
return err
|
|
}
|
|
path = expand(path)
|
|
if !force {
|
|
if _, err := os.Stat(path); err == nil {
|
|
return fmt.Errorf("refusing to overwrite %s (use -f)", path)
|
|
}
|
|
}
|
|
return rnsutil.WriteFileAtomic(path, data)
|
|
}
|
|
|
|
func expand(path string) string {
|
|
if path == "" {
|
|
return path
|
|
}
|
|
if strings.HasPrefix(path, "~/") {
|
|
home, err := os.UserHomeDir()
|
|
if err == nil {
|
|
return filepath.Join(home, path[2:])
|
|
}
|
|
}
|
|
return path
|
|
}
|