use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
pub(crate) fn env_arg(env_file: &Path) -> String {
format!("--env={}", env_file.display())
}
pub(crate) fn parse_env_arg(command: &str) -> Option<PathBuf> {
let mut words = command.split_whitespace();
while let Some(word) = words.next() {
let value = if let Some(value) = word.strip_prefix("--env=") {
value
} else if word == "--env" {
words.next().unwrap_or("")
} else {
continue;
};
if value.is_empty() {
return None;
}
return Some(PathBuf::from(value));
}
None
}
const DEV_SEED_IKM: &[u8] = b"node-dev-mode-seed-encryption-key-v1";
const SEED_HKDF_INFO: &[u8] = b"node-seed-encryption-v1";
fn dev_seed_key() -> [u8; 32] {
let mut key = [0_u8; 32];
hkdf::Hkdf::<sha2::Sha256>::new(None, DEV_SEED_IKM)
.expand(SEED_HKDF_INFO, &mut key)
.expect("32 bytes is a valid HKDF-SHA256 output length");
key
}
pub(crate) fn ensure_dev_server_seed(path: &Path) -> Result<bool> {
use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
if path.exists() {
return Ok(false);
}
let mnemonic = bip39::Mnemonic::generate(12).context("generate a BIP39 mnemonic")?;
let cipher = aes_gcm::Aes256Gcm::new_from_slice(&dev_seed_key())
.expect("a 32-byte key is a valid AES-256 key");
let nonce = aes_gcm::Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, mnemonic.to_string().as_bytes())
.map_err(|_| anyhow::anyhow!("encrypting the dev server seed failed"))?;
let envelope = serde_json::json!({
"version": 1,
"encryption_method": "device_entropy",
"ciphertext": BASE64.encode(&ciphertext),
"nonce": BASE64.encode(nonce),
"identifiers_used": [],
});
write_private(path, envelope.to_string().as_bytes())?;
Ok(true)
}
const CA_HKDF_INFO: &[u8] = b"acme-key-encryption-v1";
const CA_OUTER_SALT: &[u8] = b"node-backend-acme-encryption-salt";
fn ca_seal_key(signer_seed: &[u8]) -> [u8; 32] {
use hkdf::Hkdf;
use sha2::Sha256;
let mut stage1 = [0_u8; 32];
Hkdf::<Sha256>::new(Some(CA_OUTER_SALT), &signer_seed[..32])
.expand(CA_HKDF_INFO, &mut stage1)
.expect("32 bytes is a valid HKDF-SHA256 output length");
let mut key = [0_u8; 32];
Hkdf::<Sha256>::new(None, &stage1)
.expand(CA_HKDF_INFO, &mut key)
.expect("32 bytes is a valid HKDF-SHA256 output length");
key
}
pub(crate) struct LocalCaFiles {
pub ssl_dir: PathBuf,
pub signer_seed: PathBuf,
pub ca_certificate: PathBuf,
pub ca_key_sealed: PathBuf,
pub leaf_certificate: PathBuf,
pub leaf_key: PathBuf,
}
impl LocalCaFiles {
pub(crate) fn under(data_dir: &Path) -> Self {
let ssl_dir = data_dir.join("ssl");
Self {
signer_seed: data_dir.join("signer_seed.hex"),
ca_certificate: ssl_dir.join("local-ca.crt"),
ca_key_sealed: ssl_dir.join("local-ca.key.enc"),
leaf_certificate: ssl_dir.join("local.crt"),
leaf_key: ssl_dir.join("local.key"),
ssl_dir,
}
}
}
pub(crate) fn ensure_dev_local_ca(data_dir: &Path, common_name: &str) -> Result<bool> {
use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
use rand::RngCore;
use rcgen::{
BasicConstraints, CertificateParams, DistinguishedName, DnType, GeneralSubtree, IsCa,
KeyPair, KeyUsagePurpose, NameConstraints,
};
let files = LocalCaFiles::under(data_dir);
if files.ca_certificate.exists() && files.ca_key_sealed.exists() && files.signer_seed.exists()
{
return Ok(false);
}
std::fs::create_dir_all(&files.ssl_dir)
.with_context(|| format!("create {}", files.ssl_dir.display()))?;
let mut seed = [0_u8; 32];
rand::thread_rng().fill_bytes(&mut seed);
let permitted_ips: [(std::net::IpAddr, u8); 7] = [
([10, 0, 0, 0].into(), 8),
([172, 16, 0, 0].into(), 12),
([192, 168, 0, 0].into(), 16),
([169, 254, 0, 0].into(), 16),
([127, 0, 0, 0].into(), 8),
(std::net::Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 0).into(), 10),
(std::net::Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 0).into(), 7),
];
let mut permitted = vec![GeneralSubtree::DnsName("local".to_string())];
permitted.extend(permitted_ips.iter().map(|(base, prefix)| {
GeneralSubtree::IpAddress(rcgen::CidrSubnet::from_addr_prefix(*base, *prefix))
}));
let mut params = CertificateParams::default();
let mut name = DistinguishedName::new();
name.push(DnType::CommonName, format!("{common_name} dev local CA"));
params.distinguished_name = name;
params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
params.name_constraints = Some(NameConstraints {
permitted_subtrees: permitted,
excluded_subtrees: Vec::new(),
});
params.not_before = time::OffsetDateTime::now_utc() - time::Duration::minutes(5);
params.not_after = time::OffsetDateTime::now_utc() + time::Duration::days(3650);
let key = KeyPair::generate().context("generate the dev local CA key")?;
let certificate = params
.self_signed(&key)
.context("self-sign the dev local CA")?;
let cipher = aes_gcm::Aes256Gcm::new_from_slice(&ca_seal_key(&seed))
.expect("a 32-byte key is a valid AES-256 key");
let nonce = aes_gcm::Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, key.serialize_pem().as_bytes())
.map_err(|_| anyhow::anyhow!("sealing the dev local CA key failed"))?;
let mut sealed = nonce.to_vec();
sealed.extend_from_slice(&ciphertext);
let _ = std::fs::remove_file(&files.leaf_certificate);
let _ = std::fs::remove_file(&files.leaf_key);
write_private(&files.signer_seed, hex::encode(seed).as_bytes())?;
write_private(&files.ca_key_sealed, &sealed)?;
std::fs::write(&files.ca_certificate, certificate.pem())
.with_context(|| format!("write {}", files.ca_certificate.display()))?;
Ok(true)
}
pub(crate) struct ProvisioningLayout<'a> {
pub env_dir: &'a Path,
pub http_port: u16,
pub https_port: u16,
pub runtime_socket: &'a Path,
pub control_socket: &'a Path,
pub tls_state: &'a Path,
pub data_dir: &'a Path,
}
pub(crate) fn provisioning_env(layout: &ProvisioningLayout<'_>) -> Vec<(&'static str, String)> {
let dir = layout.env_dir.join("provisioning");
let at = |name: &str| dir.join(name).display().to_string();
vec![
("NODE_PROVISIONING_HTTP_ADDRESS", format!("127.0.0.1:{}", layout.http_port)),
("NODE_PROVISIONING_HTTPS_ADDRESS", format!("0.0.0.0:{}", layout.https_port)),
("NODE_PROVISIONING_RUNTIME_SOCKET", layout.runtime_socket.display().to_string()),
("NODE_PROVISIONING_RUNTIME_CONTROL_SOCKET", layout.control_socket.display().to_string()),
("NODE_PROVISIONING_RUNTIME_TLS_STATE", layout.tls_state.display().to_string()),
("NODE_PROVISIONING_TLS_APPROVED_ROOT", layout.data_dir.display().to_string()),
("NODE_PROVISIONING_UI_ROOT", at("ui")),
("NODE_PROVISIONING_BOOT_REPORT", at("boot-report.json")),
("NODE_PROVISIONING_APP_MILESTONES_PATH", at("app-milestones.json")),
("NODE_PROVISIONING_RUNTIME_MIGRATION_STATUS", at("runtime-migration.json")),
("NODE_PROVISIONING_WIFI_MIGRATION_STATUS", at("wifi-migration.json")),
("NODE_PROVISIONING_WIFI_EVENT_SOCKET", at("wifi-events.sock")),
("NODE_PROVISIONING_WIFI_SOCKET", at("wifi.sock")),
("NODE_PROVISIONING_WIFI_PROXY_SOCKET", at("wifi-proxy.sock")),
("NODE_PROVISIONING_LCD_SOCKET", at("lcd.sock")),
("NODE_PROVISIONING_LED_SOCKET", at("led.sock")),
("NODE_PROVISIONING_OTA_SOCKET", at("ota.sock")),
("NODE_PROVISIONING_OTA_MAINTENANCE_MARKER", at("ota-maintenance.json")),
("NODE_PROVISIONING_OTA_SNAPSHOT_ROOT", at("ota-snapshots")),
("NODE_PROVISIONING_RUNTIME_INHIBITED", at("runtime-inhibited")),
("NODE_PROVISIONING_MAINTENANCE_HEALTH_SOCKET", at("maintenance-health.sock")),
("NODE_PROVISIONING_MAINTENANCE_AUTH_STATE", at("maintenance-auth.json")),
("NODE_PROVISIONING_TIME_SYNC_MARKER", at("time-synchronized")),
("NODE_PROVISIONING_RUNTIME_UNIT", "node-app-dev-runtime.service".to_string()),
]
}
pub(crate) fn wifi_setup_answer(request_line: &str) -> Option<String> {
let request: serde_json::Value = serde_json::from_str(request_line.trim()).ok()?;
let result = match request.get("method").and_then(serde_json::Value::as_str) {
Some("core.wifi.setup_complete") => serde_json::json!({ "setup_complete": true }),
Some("core.wifi.status") => serde_json::json!({
"mode": "client",
"connection_status": "connected",
"has_internet": true,
}),
_ => serde_json::json!({}),
};
let response = serde_json::json!({
"jsonrpc": "2.0",
"schema_version": 1,
"id": request.get("id").cloned().unwrap_or(serde_json::Value::Null),
"result": result,
});
Some(format!("{response}\n"))
}
#[cfg(unix)]
pub(crate) fn serve_wifi_setup_socket(socket: &Path) -> Result<()> {
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixListener;
if let Some(parent) = socket.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let _ = std::fs::remove_file(socket);
let listener = UnixListener::bind(socket)
.with_context(|| format!("bind the dev Wi-Fi setup socket at {}", socket.display()))?;
std::thread::Builder::new()
.name("dev-wifi-setup".into())
.spawn(move || {
for stream in listener.incoming() {
let Ok(stream) = stream else { continue };
std::thread::spawn(move || {
let Ok(mut writer) = stream.try_clone() else { return };
for line in BufReader::new(stream).lines() {
let Ok(line) = line else { return };
if let Some(answer) = wifi_setup_answer(&line) {
if writer.write_all(answer.as_bytes()).is_err() {
return;
}
}
}
});
}
})
.context("spawn the dev Wi-Fi setup responder")?;
Ok(())
}
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
use std::io::Write;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create {}", parent.display()))?;
}
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options
.open(path)
.with_context(|| format!("open {}", path.display()))?;
file.write_all(bytes)
.with_context(|| format!("write {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_env_argument_is_the_single_equals_form_node_server_parses() {
assert_eq!(
env_arg(Path::new("/c/node-app/monorepo-1/daemon.env")),
"--env=/c/node-app/monorepo-1/daemon.env"
);
}
#[test]
fn parse_env_arg_reads_both_spellings() {
for command in [
"/t/node-server --env=/c/monorepo-1/daemon.env",
"/t/node-server --env /c/monorepo-1/daemon.env",
] {
assert_eq!(
parse_env_arg(command),
Some(PathBuf::from("/c/monorepo-1/daemon.env")),
"{command}"
);
}
assert_eq!(parse_env_arg("/t/node-server"), None);
assert_eq!(parse_env_arg("/t/node-server --env="), None);
assert_eq!(parse_env_arg("/t/node-server --environment=x"), None);
}
#[test]
fn the_dev_seed_decrypts_under_the_development_mode_key_and_is_never_replaced() {
use aes_gcm::aead::{Aead, KeyInit};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server_seed.enc");
assert!(ensure_dev_server_seed(&path).unwrap());
let first = std::fs::read_to_string(&path).unwrap();
assert!(!ensure_dev_server_seed(&path).unwrap());
assert_eq!(std::fs::read_to_string(&path).unwrap(), first);
let envelope: serde_json::Value = serde_json::from_str(&first).unwrap();
assert_eq!(envelope["version"], 1);
assert_eq!(envelope["encryption_method"], "device_entropy");
let nonce = BASE64.decode(envelope["nonce"].as_str().unwrap()).unwrap();
let ciphertext = BASE64
.decode(envelope["ciphertext"].as_str().unwrap())
.unwrap();
let plaintext = aes_gcm::Aes256Gcm::new_from_slice(&dev_seed_key())
.unwrap()
.decrypt(aes_gcm::Nonce::from_slice(&nonce), ciphertext.as_slice())
.unwrap();
let words = String::from_utf8(plaintext).unwrap();
assert!(bip39::Mnemonic::parse(&words).is_ok());
assert_eq!(words.split_whitespace().count(), 12);
}
#[test]
fn the_local_ca_key_unseals_under_the_signer_seed() {
use aes_gcm::aead::{Aead, KeyInit};
let dir = tempfile::tempdir().unwrap();
assert!(ensure_dev_local_ca(dir.path(), "alice").unwrap());
assert!(!ensure_dev_local_ca(dir.path(), "alice").unwrap());
let files = LocalCaFiles::under(dir.path());
assert_eq!(files.signer_seed, dir.path().join("signer_seed.hex"));
assert_eq!(files.ca_certificate, dir.path().join("ssl/local-ca.crt"));
let seed = hex::decode(std::fs::read_to_string(&files.signer_seed).unwrap()).unwrap();
let sealed = std::fs::read(&files.ca_key_sealed).unwrap();
let (nonce, ciphertext) = sealed.split_at(12);
let key_pem = aes_gcm::Aes256Gcm::new_from_slice(&ca_seal_key(&seed))
.unwrap()
.decrypt(aes_gcm::Nonce::from_slice(nonce), ciphertext)
.unwrap();
let key = rcgen::KeyPair::from_pem(std::str::from_utf8(&key_pem).unwrap()).unwrap();
let ca_pem = std::fs::read_to_string(&files.ca_certificate).unwrap();
let ca = rcgen::CertificateParams::from_ca_cert_pem(&ca_pem).unwrap();
assert!(matches!(ca.is_ca, rcgen::IsCa::Ca(_)));
let ca_cert = ca.self_signed(&key).unwrap();
let mut leaf = rcgen::CertificateParams::new(vec!["alice.local".to_string()]).unwrap();
leaf.subject_alt_names
.push(rcgen::SanType::IpAddress([127, 0, 0, 1].into()));
let leaf_key = rcgen::KeyPair::generate().unwrap();
leaf.signed_by(&leaf_key, &ca_cert, &key).unwrap();
}
#[test]
fn the_dev_wifi_answer_reports_setup_complete_in_the_envelope_provisioning_checks() {
let answer = wifi_setup_answer(
r#"{"jsonrpc":"2.0","schema_version":1,"id":"a1","method":"core.wifi.setup_complete","params":{}}"#,
)
.unwrap();
assert!(answer.ends_with('\n'));
let value: serde_json::Value = serde_json::from_str(&answer).unwrap();
assert_eq!(value["jsonrpc"], "2.0");
assert_eq!(value["schema_version"], 1);
assert_eq!(value["id"], "a1");
assert_eq!(value["result"]["setup_complete"], true);
let status: serde_json::Value = serde_json::from_str(
&wifi_setup_answer(r#"{"jsonrpc":"2.0","id":"a2","method":"core.wifi.status"}"#).unwrap(),
)
.unwrap();
assert_eq!(status["result"]["mode"], "client");
assert_eq!(status["result"]["connection_status"], "connected");
let other: serde_json::Value = serde_json::from_str(
&wifi_setup_answer(r#"{"jsonrpc":"2.0","id":"a3","method":"core.wifi.scan"}"#).unwrap(),
)
.unwrap();
assert_eq!(other["result"], serde_json::json!({}));
assert!(wifi_setup_answer("not json").is_none());
}
#[cfg(unix)]
#[test]
fn the_dev_wifi_socket_answers_over_unix_json_rpc() {
use std::io::{BufRead, BufReader, Write};
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("wifi.sock");
serve_wifi_setup_socket(&socket).unwrap();
let mut stream = std::os::unix::net::UnixStream::connect(&socket).unwrap();
stream
.write_all(b"{\"jsonrpc\":\"2.0\",\"schema_version\":1,\"id\":\"x\",\"method\":\"core.wifi.setup_complete\"}\n")
.unwrap();
let mut line = String::new();
BufReader::new(stream).read_line(&mut line).unwrap();
let value: serde_json::Value = serde_json::from_str(&line).unwrap();
assert_eq!(value["result"]["setup_complete"], true);
}
#[test]
fn provisioning_is_pointed_at_this_instance_and_nothing_under_run() {
let env_dir = Path::new("/c/monorepo-1");
let env = provisioning_env(&ProvisioningLayout {
env_dir,
http_port: 5473,
https_port: 4731,
runtime_socket: &env_dir.join("runtime.sock"),
control_socket: &env_dir.join("control.sock"),
tls_state: &env_dir.join("tls.json"),
data_dir: &env_dir.join("data"),
});
let get = |key: &str| {
env.iter()
.find(|(k, _)| *k == key)
.map(|(_, v)| v.clone())
.unwrap_or_else(|| panic!("{key} missing"))
};
assert_eq!(get("NODE_PROVISIONING_HTTPS_ADDRESS"), "0.0.0.0:4731");
assert_eq!(get("NODE_PROVISIONING_HTTP_ADDRESS"), "127.0.0.1:5473");
assert_eq!(get("NODE_PROVISIONING_RUNTIME_SOCKET"), "/c/monorepo-1/runtime.sock");
assert_eq!(get("NODE_PROVISIONING_RUNTIME_TLS_STATE"), "/c/monorepo-1/tls.json");
for (key, value) in &env {
assert!(
!value.starts_with("/run") && !value.starts_with("/var"),
"{key} still points at a device path: {value}"
);
}
}
}