use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose};
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256, Sha512};
use crate::pipeline::model::{DslFlag, DslFlagKind, LayoutItem};
use crate::pipeline::{
NodeDefinition, PipelineError,
nodes::{NodeExecutionInput, NodeExecutionOutput, NodeHandler},
};
pub const NODE_KIND: &str = "n.crypto";
const INPUT_PIN_IN: &str = "in";
const OUTPUT_PIN_OUT: &str = "out";
const OUTPUT_PIN_TRUE: &str = "true";
const OUTPUT_PIN_FALSE: &str = "false";
pub fn definition() -> NodeDefinition {
NodeDefinition {
kind: NODE_KIND.to_string(),
title: "Crypto".to_string(),
description: "Cryptographic primitives: hash, verify, HMAC, base64, random. \
Use --op to select the operation. Hash/encode ops replace the payload with \
{ result } and emit to the 'out' pin. Verify ops (bcrypt_verify, argon2_verify) \
route to 'true' or 'false' pin, forwarding the payload unchanged. \
Input defaults to payload.input; override with --input-path. \
Use $trigger or $nodes references for upstream data."
.to_string(),
input_schema: json!({
"type": "object",
"properties": {
"input": {
"type": "string",
"description": "Primary input value (password, message, etc.)."
},
"hash": {
"type": "string",
"description": "Stored hash for verify operations."
},
"key": {
"type": "string",
"description": "HMAC secret key."
}
}
}),
output_schema: json!({
"type": "object",
"properties": {
"result": {
"type": "string",
"description": "Computed value (hex digest, base64 string, bcrypt/argon2 hash, etc.)."
}
}
}),
input_pins: vec![INPUT_PIN_IN.to_string()],
output_pins: vec![
OUTPUT_PIN_OUT.to_string(),
OUTPUT_PIN_TRUE.to_string(),
OUTPUT_PIN_FALSE.to_string(),
],
script_available: false,
script_bridge: None,
config_schema: Default::default(),
dsl_flags: vec![
DslFlag { flag: "--op".to_string(), config_key: "op".to_string(), description: "Cryptographic operation: sha256, sha512, bcrypt_hash, bcrypt_verify, argon2_hash, argon2_verify, hmac_sha256, base64_encode, base64_decode, random_hex.".to_string(), kind: DslFlagKind::Scalar, required: true },
DslFlag { flag: "--input-path".to_string(), config_key: "input_path".to_string(), description: "JSON pointer for primary input value (default: payload.input).".to_string(), kind: DslFlagKind::Scalar, required: false },
DslFlag { flag: "--hash-path".to_string(), config_key: "hash_path".to_string(), description: "JSON pointer for stored hash for verify operations (default: payload.hash).".to_string(), kind: DslFlagKind::Scalar, required: false },
DslFlag { flag: "--key-path".to_string(), config_key: "key_path".to_string(), description: "JSON pointer for HMAC secret key (default: payload.key).".to_string(), kind: DslFlagKind::Scalar, required: false },
DslFlag { flag: "--cost".to_string(), config_key: "cost".to_string(), description: "bcrypt cost factor 4-31 (default 12).".to_string(), kind: DslFlagKind::Scalar, required: false },
DslFlag { flag: "--length".to_string(), config_key: "length".to_string(), description: "Random byte count for random_hex (default 32).".to_string(), kind: DslFlagKind::Scalar, required: false },
],
fields: {
use crate::pipeline::model::{NodeFieldDef, NodeFieldType, SelectOptionDef};
vec![
NodeFieldDef { name: "op".to_string(), label: "Operation".to_string(), field_type: NodeFieldType::Select, options: vec![
SelectOptionDef { value: "sha256".to_string(), label: "SHA-256 hash".to_string() },
SelectOptionDef { value: "sha512".to_string(), label: "SHA-512 hash".to_string() },
SelectOptionDef { value: "bcrypt_hash".to_string(), label: "bcrypt hash".to_string() },
SelectOptionDef { value: "bcrypt_verify".to_string(), label: "bcrypt verify → true/false pin".to_string() },
SelectOptionDef { value: "argon2_hash".to_string(), label: "Argon2id hash".to_string() },
SelectOptionDef { value: "argon2_verify".to_string(), label: "Argon2id verify → true/false pin".to_string() },
SelectOptionDef { value: "hmac_sha256".to_string(), label: "HMAC-SHA256".to_string() },
SelectOptionDef { value: "base64_encode".to_string(), label: "Base64 encode".to_string() },
SelectOptionDef { value: "base64_decode".to_string(), label: "Base64 decode".to_string() },
SelectOptionDef { value: "random_hex".to_string(), label: "Random hex token".to_string() },
], help: Some("Cryptographic operation to perform. Verify operations route to true/false pins.".to_string()), ..Default::default() },
NodeFieldDef { name: "input_path".to_string(), label: "Input Path".to_string(), field_type: NodeFieldType::Text, help: Some("JSON pointer for the primary input value. Empty reads payload.input.".to_string()), ..Default::default() },
NodeFieldDef { name: "hash_path".to_string(), label: "Hash Path (verify)".to_string(), field_type: NodeFieldType::Text, help: Some("JSON pointer for the stored hash used by verify operations. Empty reads payload.hash.".to_string()), ..Default::default() },
NodeFieldDef { name: "key_path".to_string(), label: "Key Path (HMAC)".to_string(), field_type: NodeFieldType::Text, help: Some("JSON pointer for the HMAC secret key. Empty reads payload.key.".to_string()), ..Default::default() },
NodeFieldDef { name: "cost".to_string(), label: "Cost (bcrypt)".to_string(), field_type: NodeFieldType::Text, help: Some("bcrypt cost factor from 4 to 31. Defaults to 12.".to_string()), ..Default::default() },
NodeFieldDef { name: "length".to_string(), label: "Length (random_hex)".to_string(), field_type: NodeFieldType::Text, help: Some("Random byte count for random_hex output. Defaults to 32.".to_string()), ..Default::default() },
]
},
layout: vec![
LayoutItem::Field("op".to_string()),
LayoutItem::Row { row: vec![LayoutItem::Field("input_path".to_string()), LayoutItem::Field("hash_path".to_string())] },
LayoutItem::Row { row: vec![LayoutItem::Field("key_path".to_string()), LayoutItem::Field("cost".to_string())] },
LayoutItem::Field("length".to_string()),
],
ai_tool: Default::default(),
..Default::default()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
pub op: String,
#[serde(default)]
pub input_path: String,
#[serde(default)]
pub hash_path: String,
#[serde(default)]
pub key_path: String,
#[serde(default)]
pub cost: Option<u32>,
#[serde(default)]
pub length: Option<u32>,
}
pub struct Node {
config: Config,
}
impl Node {
pub fn new(config: Config) -> Result<Self, PipelineError> {
const VALID_OPS: &[&str] = &[
"sha256",
"sha512",
"bcrypt_hash",
"bcrypt_verify",
"argon2_hash",
"argon2_verify",
"hmac_sha256",
"base64_encode",
"base64_decode",
"random_hex",
];
if config.op.is_empty() {
return Err(PipelineError::new(
"FW_NODE_CRYPTO_CONFIG",
"n.crypto: --op must be specified",
));
}
if !VALID_OPS.contains(&config.op.as_str()) {
return Err(PipelineError::new(
"FW_NODE_CRYPTO_OP",
format!(
"n.crypto: unknown op '{}'. Valid ops: {}",
config.op,
VALID_OPS.join(", ")
),
));
}
Ok(Self { config })
}
}
fn extract_str<'a>(payload: &'a Value, path: &str, fallback_key: &str) -> &'a str {
if !path.is_empty() {
let ptr = if path.starts_with('/') {
path.to_string()
} else {
format!("/{}", path)
};
return payload
.pointer(&ptr)
.and_then(Value::as_str)
.unwrap_or_default();
}
payload
.get(fallback_key)
.and_then(Value::as_str)
.unwrap_or_default()
}
fn with_result(result: impl Into<Value>) -> Value {
json!({ "result": result.into() })
}
#[async_trait]
impl NodeHandler for Node {
fn kind(&self) -> &'static str {
NODE_KIND
}
fn input_pins(&self) -> &'static [&'static str] {
&[INPUT_PIN_IN]
}
fn output_pins(&self) -> &'static [&'static str] {
&[OUTPUT_PIN_OUT, OUTPUT_PIN_TRUE, OUTPUT_PIN_FALSE]
}
async fn execute_async(
&self,
input: NodeExecutionInput,
) -> Result<NodeExecutionOutput, PipelineError> {
let payload = input.payload;
match self.config.op.as_str() {
"sha256" => {
let input_val = extract_str(&payload, &self.config.input_path, "input").to_string();
let mut h = Sha256::new();
h.update(input_val.as_bytes());
let result = hex::encode(h.finalize());
Ok(NodeExecutionOutput {
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
payload: with_result(result),
trace: vec!["n.crypto: sha256".to_string()],
})
}
"sha512" => {
let input_val = extract_str(&payload, &self.config.input_path, "input").to_string();
let mut h = Sha512::new();
h.update(input_val.as_bytes());
let result = hex::encode(h.finalize());
Ok(NodeExecutionOutput {
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
payload: with_result(result),
trace: vec!["n.crypto: sha512".to_string()],
})
}
"bcrypt_hash" => {
let input_val = extract_str(&payload, &self.config.input_path, "input").to_string();
let cost = self.config.cost.unwrap_or(12);
let result = tokio::task::spawn_blocking(move || {
bcrypt::hash(&input_val, cost).map_err(|e| e.to_string())
})
.await
.map_err(|e| PipelineError::new("FW_NODE_CRYPTO_SPAWN", e.to_string()))?
.map_err(|e| PipelineError::new("FW_NODE_CRYPTO_BCRYPT_HASH", e))?;
Ok(NodeExecutionOutput {
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
payload: with_result(result),
trace: vec![format!("n.crypto: bcrypt_hash cost={cost}")],
})
}
"bcrypt_verify" => {
let plaintext = extract_str(&payload, &self.config.input_path, "input").to_string();
let stored = extract_str(&payload, &self.config.hash_path, "hash").to_string();
let is_valid = tokio::task::spawn_blocking(move || {
bcrypt::verify(&plaintext, &stored).unwrap_or(false)
})
.await
.map_err(|e| PipelineError::new("FW_NODE_CRYPTO_SPAWN", e.to_string()))?;
let pin = if is_valid {
OUTPUT_PIN_TRUE
} else {
OUTPUT_PIN_FALSE
};
Ok(NodeExecutionOutput {
output_pins: vec![pin.to_string()],
payload,
trace: vec![format!("n.crypto: bcrypt_verify result={is_valid}")],
})
}
"argon2_hash" => {
let input_val = extract_str(&payload, &self.config.input_path, "input").to_string();
let result = tokio::task::spawn_blocking(move || {
use argon2::{
Argon2,
password_hash::{PasswordHasher, SaltString, rand_core::OsRng},
};
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(input_val.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| e.to_string())
})
.await
.map_err(|e| PipelineError::new("FW_NODE_CRYPTO_SPAWN", e.to_string()))?
.map_err(|e| PipelineError::new("FW_NODE_CRYPTO_ARGON2_HASH", e))?;
Ok(NodeExecutionOutput {
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
payload: with_result(result),
trace: vec!["n.crypto: argon2_hash".to_string()],
})
}
"argon2_verify" => {
let plaintext = extract_str(&payload, &self.config.input_path, "input").to_string();
let stored = extract_str(&payload, &self.config.hash_path, "hash").to_string();
let is_valid = tokio::task::spawn_blocking(move || {
use argon2::{
Argon2,
password_hash::{PasswordHash, PasswordVerifier},
};
PasswordHash::new(&stored)
.map(|h| {
Argon2::default()
.verify_password(plaintext.as_bytes(), &h)
.is_ok()
})
.unwrap_or(false)
})
.await
.map_err(|e| PipelineError::new("FW_NODE_CRYPTO_SPAWN", e.to_string()))?;
let pin = if is_valid {
OUTPUT_PIN_TRUE
} else {
OUTPUT_PIN_FALSE
};
Ok(NodeExecutionOutput {
output_pins: vec![pin.to_string()],
payload,
trace: vec![format!("n.crypto: argon2_verify result={is_valid}")],
})
}
"hmac_sha256" => {
let input_val = extract_str(&payload, &self.config.input_path, "input").to_string();
let key_val = extract_str(&payload, &self.config.key_path, "key").to_string();
type HmacSha256 = Hmac<Sha256>;
let mut mac = HmacSha256::new_from_slice(key_val.as_bytes())
.map_err(|e| PipelineError::new("FW_NODE_CRYPTO_HMAC_KEY", e.to_string()))?;
mac.update(input_val.as_bytes());
let result = hex::encode(mac.finalize().into_bytes());
Ok(NodeExecutionOutput {
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
payload: with_result(result),
trace: vec!["n.crypto: hmac_sha256".to_string()],
})
}
"base64_encode" => {
let input_val = extract_str(&payload, &self.config.input_path, "input").to_string();
let result = general_purpose::STANDARD.encode(input_val.as_bytes());
Ok(NodeExecutionOutput {
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
payload: with_result(result),
trace: vec!["n.crypto: base64_encode".to_string()],
})
}
"base64_decode" => {
let input_val = extract_str(&payload, &self.config.input_path, "input").to_string();
let bytes = general_purpose::STANDARD
.decode(input_val.as_bytes())
.map_err(|e| {
PipelineError::new("FW_NODE_CRYPTO_BASE64_DECODE", e.to_string())
})?;
let result = String::from_utf8(bytes)
.map_err(|e| PipelineError::new("FW_NODE_CRYPTO_BASE64_UTF8", e.to_string()))?;
Ok(NodeExecutionOutput {
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
payload: with_result(result),
trace: vec!["n.crypto: base64_decode".to_string()],
})
}
"random_hex" => {
let length = self.config.length.unwrap_or(32) as usize;
let result = tokio::task::spawn_blocking(move || {
use rand::RngExt;
let mut rng = rand::rng();
let bytes: Vec<u8> = (0..length).map(|_| rng.random::<u8>()).collect();
hex::encode(&bytes)
})
.await
.map_err(|e| PipelineError::new("FW_NODE_CRYPTO_SPAWN", e.to_string()))?;
Ok(NodeExecutionOutput {
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
payload: with_result(result),
trace: vec![format!("n.crypto: random_hex length={length}")],
})
}
other => Err(PipelineError::new(
"FW_NODE_CRYPTO_OP",
format!("n.crypto: unknown op '{other}'"),
)),
}
}
}