agentknock 0.1.0

Developer secrets on your phone, provided only to approved commands.
Documentation
#![cfg(unix)]

mod support;

use std::{
    fs,
    io::Write as _,
    process::{Command, Stdio},
};

use serde_json::json;

use support::{
    TestHome, accept, assert_authenticated_request, encrypt_response, open_completion,
    open_request, receive_json, send_json, websocket_server,
};

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn uploads_an_environment_secret_from_multiple_sources() {
    let home = TestHome::active();
    let value_path = home.path().join("token");
    let environment_path = home.path().join("secret.env");
    fs::write(&value_path, "file value\n").unwrap();
    fs::write(&environment_path, "FROM_ENV_FILE='dotenv value'\n").unwrap();

    let device_private_key = home.device_private_key.clone();
    let (relay_url, server) = websocket_server(move |listener| async move {
        let (upgrade, mut socket) = accept(&listener).await;
        assert_authenticated_request(&upgrade);
        let frame = receive_json(&mut socket).await;
        let client_id = frame["client_id"].as_str().unwrap().to_owned();
        let request_id = frame["request_id"].as_str().unwrap().to_owned();
        let (mut context, key, plaintext) =
            open_request(&device_private_key, &request_id, &frame["payload"]);
        assert_eq!(
            plaintext,
            json!({
                "app_info": {
                    "name": "agentknock",
                    "version": env!("CARGO_PKG_VERSION"),
                },
                "lib_info": {
                    "name": "agentknock",
                    "version": env!("CARGO_PKG_VERSION"),
                },
                "method": "SecretUpload",
                "mode": "CREATE",
                "secret": {
                    "name": "bootstrap",
                    "description": "Initial imported values",
                    "type": "environment",
                    "variables": {
                        "FROM_ENV_FILE": {"value": "dotenv value"},
                        "FROM_FILE": {"value": "file value\n"},
                        "FROM_PROCESS": {"value": "process value"},
                    },
                },
            })
        );

        send_json(
            &mut socket,
            json!({
                "type": "ack",
                "client_id": client_id,
                "request_id": request_id,
                "kind": "request",
            }),
        )
        .await;
        send_json(
            &mut socket,
            json!({
                "type": "message",
                "client_id": client_id,
                "request_id": request_id,
                "kind": "response",
                "payload": encrypt_response(&context, &key, &json!({"result": "RECEIVED"})),
            }),
        )
        .await;
        assert_eq!(receive_json(&mut socket).await["kind"], "response");
        let completion = receive_json(&mut socket).await;
        assert_eq!(
            open_completion(&mut context, &completion["payload"]),
            json!({
                "app_info": {
                    "name": "agentknock",
                    "version": env!("CARGO_PKG_VERSION"),
                },
                "lib_info": {
                    "name": "agentknock",
                    "version": env!("CARGO_PKG_VERSION"),
                },
                "result": "RECEIVED",
            })
        );
        send_json(
            &mut socket,
            json!({
                "type": "ack",
                "client_id": client_id,
                "request_id": request_id,
                "kind": "completion",
            }),
        )
        .await;
    })
    .await;

    let output = Command::new(env!("CARGO_BIN_EXE_agentknock"))
        .env("HOME", home.path())
        .env("AGENTKNOCK_TEST_RELAY_URL", relay_url)
        .env("FROM_PROCESS", "process value")
        .args([
            "secret",
            "upload",
            "bootstrap",
            "--description",
            "Initial imported values",
            "--from-env",
            "FROM_PROCESS",
            "--from-file",
            &format!("FROM_FILE={}", value_path.display()),
            "--from-env-file",
            environment_path.to_str().unwrap(),
        ])
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert_eq!(
        String::from_utf8(output.stdout).unwrap(),
        concat!(
            "Secret upload \"bootstrap\" delivered to the device.\n",
            "The secret isn't available until you approve the upload on the device.\n",
            "Suggested action: Review the secret upload on the device.\n",
        )
    );
    assert!(
        !String::from_utf8(output.stderr)
            .unwrap()
            .contains("process value")
    );
    server.await.unwrap();
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn updates_an_environment_variable_from_standard_input() {
    let home = TestHome::active();
    let device_private_key = home.device_private_key.clone();
    let (relay_url, server) = websocket_server(move |listener| async move {
        let (_, mut socket) = accept(&listener).await;
        let frame = receive_json(&mut socket).await;
        let client_id = frame["client_id"].as_str().unwrap().to_owned();
        let request_id = frame["request_id"].as_str().unwrap().to_owned();
        let (mut context, key, plaintext) =
            open_request(&device_private_key, &request_id, &frame["payload"]);
        assert_eq!(plaintext["mode"], "UPDATE");
        assert_eq!(plaintext["secret"]["type"], "environment");
        assert_eq!(
            plaintext["secret"]["variables"]["TOKEN"]["value"],
            "standard input value\n"
        );

        send_json(
            &mut socket,
            json!({
                "type": "ack",
                "client_id": client_id,
                "request_id": request_id,
                "kind": "request",
            }),
        )
        .await;
        send_json(
            &mut socket,
            json!({
                "type": "message",
                "client_id": client_id,
                "request_id": request_id,
                "kind": "response",
                "payload": encrypt_response(&context, &key, &json!({"result": "RECEIVED"})),
            }),
        )
        .await;
        assert_eq!(receive_json(&mut socket).await["kind"], "response");
        let completion = receive_json(&mut socket).await;
        let _ = open_completion(&mut context, &completion["payload"]);
        send_json(
            &mut socket,
            json!({
                "type": "ack",
                "client_id": client_id,
                "request_id": request_id,
                "kind": "completion",
            }),
        )
        .await;
    })
    .await;

    let mut child = Command::new(env!("CARGO_BIN_EXE_agentknock"))
        .env("HOME", home.path())
        .env("AGENTKNOCK_TEST_RELAY_URL", relay_url)
        .args([
            "secret",
            "upload",
            "existing",
            "--update",
            "--from-file",
            "TOKEN=-",
        ])
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .unwrap();
    child
        .stdin
        .take()
        .unwrap()
        .write_all(b"standard input value\n")
        .unwrap();
    let output = child.wait_with_output().unwrap();
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
    server.await.unwrap();
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reports_a_rejected_secret_upload() {
    let home = TestHome::active();
    let device_private_key = home.device_private_key.clone();
    let (relay_url, server) = websocket_server(move |listener| async move {
        let (_, mut socket) = accept(&listener).await;
        let frame = receive_json(&mut socket).await;
        let client_id = frame["client_id"].as_str().unwrap().to_owned();
        let request_id = frame["request_id"].as_str().unwrap().to_owned();
        let (mut context, key, _) =
            open_request(&device_private_key, &request_id, &frame["payload"]);
        send_json(
            &mut socket,
            json!({
                "type": "ack",
                "client_id": client_id,
                "request_id": request_id,
                "kind": "request",
            }),
        )
        .await;
        send_json(
            &mut socket,
            json!({
                "type": "message",
                "client_id": client_id,
                "request_id": request_id,
                "kind": "response",
                "payload": encrypt_response(
                    &context,
                    &key,
                    &json!({
                        "result": "REJECTED",
                        "message": "The upload is not valid.",
                    }),
                ),
            }),
        )
        .await;
        assert_eq!(receive_json(&mut socket).await["kind"], "response");
        let completion = receive_json(&mut socket).await;
        assert_eq!(
            open_completion(&mut context, &completion["payload"])["result"],
            "REJECTED"
        );
        send_json(
            &mut socket,
            json!({
                "type": "ack",
                "client_id": client_id,
                "request_id": request_id,
                "kind": "completion",
            }),
        )
        .await;
    })
    .await;

    let output = Command::new(env!("CARGO_BIN_EXE_agentknock"))
        .env("HOME", home.path())
        .env("AGENTKNOCK_TEST_RELAY_URL", relay_url)
        .env("TOKEN", "must not appear")
        .args(["secret", "upload", "rejected", "--from-env", "TOKEN"])
        .output()
        .unwrap();
    assert!(!output.status.success());
    let stderr = String::from_utf8(output.stderr).unwrap();
    assert!(stderr.contains("The device rejected the secret upload"));
    assert!(stderr.contains("The upload is not valid."));
    assert!(!stderr.contains("must not appear"));
    server.await.unwrap();
}

#[test]
fn rejects_multiple_standard_input_sources_before_connecting() {
    let home = TestHome::active();
    let output = Command::new(env!("CARGO_BIN_EXE_agentknock"))
        .env("HOME", home.path())
        .args([
            "secret",
            "upload",
            "invalid",
            "--from-file",
            "TOKEN=-",
            "--from-env-file",
            "-",
        ])
        .output()
        .unwrap();

    assert!(!output.status.success());
    assert!(
        String::from_utf8(output.stderr)
            .unwrap()
            .contains("only one secret source can read from standard input")
    );
}

#[test]
fn does_not_print_a_secret_from_an_invalid_environment_file() {
    let home = TestHome::active();
    let environment_path = home.path().join("invalid.env");
    fs::write(&environment_path, "TOKEN='do-not-print-this\n").unwrap();
    let output = Command::new(env!("CARGO_BIN_EXE_agentknock"))
        .env("HOME", home.path())
        .args([
            "secret",
            "upload",
            "invalid",
            "--from-env-file",
            environment_path.to_str().unwrap(),
        ])
        .output()
        .unwrap();

    assert!(!output.status.success());
    let stderr = String::from_utf8(output.stderr).unwrap();
    assert!(stderr.contains("environment file"));
    assert!(stderr.contains("isn't valid"));
    assert!(!stderr.contains("do-not-print-this"));
}