// 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") bindFlagUsage(fs, "rgoid - Reticulum identity tool", "Generate, import, export, sign, encrypt, and verify identity material.", []helpLine{ {Cmd: "rgoid [flags]"}, {Cmd: "reticulum-go id [flags]"}, }, "rgoid -g ~/.reticulum/identity", "rgoid -i identity -p", "rgoid -i identity -s document.pdf", ) 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 }