Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions src-tauri/src/core/remote_bootstrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,112 @@ echo "treq bootstrap: manifest version $TREQ_MANIFEST_VERSION installed"
)
}

/// Idempotent shell command that installs the Treq SSH CA's public key on a
/// managed VM and points `sshd`'s `TrustedUserCAKeys` at it (PRD "Configure
/// the managed VM to trust the Treq SSH CA"). Mirrors
/// `_shared/remote/ssh-vm-config.ts::installCaTrustCommand` in the Edge
/// Function, which is the path actually wired into provisioning today; this
/// Rust copy exists so a native (Phase 4) transport can push the same
/// config without going through the control plane's exec channel.
pub fn install_ca_trust_command(ca_public_key_line: &str) -> Vec<String> {
let script = format!(
r#"#!/bin/sh
set -eu
mkdir -p /etc/ssh/sshd_config.d
cat > /etc/ssh/treq_ca.pub <<'TREQ_CA_EOF'
{ca_public_key_line}
TREQ_CA_EOF
cat > /etc/ssh/sshd_config.d/60-treq-ca.conf <<'TREQ_SSHD_EOF'
TrustedUserCAKeys /etc/ssh/treq_ca.pub
TREQ_SSHD_EOF
if command -v systemctl >/dev/null 2>&1 && systemctl is-active sshd >/dev/null 2>&1; then
systemctl reload sshd
elif command -v systemctl >/dev/null 2>&1 && systemctl is-active ssh >/dev/null 2>&1; then
systemctl reload ssh
elif [ -f /var/run/sshd.pid ]; then
kill -HUP "$(cat /var/run/sshd.pid)"
fi
echo "treq: CA trust installed"
"#
);
vec!["/bin/sh".to_string(), "-c".to_string(), script]
}

/// Idempotent authorized_keys install for the direct existing-key auth
/// alternative (PRD "Existing keys without certificates"). Guards on a
/// fingerprint marker comment so a repeated install never duplicates the
/// entry. Mirrors `_shared/remote/ssh-vm-config.ts::installAuthorizedKeyCommand`.
pub fn install_authorized_key_command(
public_key_line: &str,
fingerprint_sha256: &str,
) -> Vec<String> {
let marker = format!("# treq-client-key:{fingerprint_sha256}");
let script = format!(
r#"#!/bin/sh
set -eu
mkdir -p /home/treq/.ssh
chmod 700 /home/treq/.ssh
touch /home/treq/.ssh/authorized_keys
if ! grep -qF "{marker}" /home/treq/.ssh/authorized_keys 2>/dev/null; then
printf '%s\n%s\n' "{marker}" "{public_key_line}" >> /home/treq/.ssh/authorized_keys
fi
chmod 600 /home/treq/.ssh/authorized_keys
echo "treq: authorized key installed"
"#
);
vec!["/bin/sh".to_string(), "-c".to_string(), script]
}

/// Removes exactly the marker + key line pair `install_authorized_key_command`
/// added for `fingerprint_sha256`, leaving every other entry untouched.
/// Mirrors `_shared/remote/ssh-vm-config.ts::removeAuthorizedKeyCommand`.
pub fn remove_authorized_key_command(fingerprint_sha256: &str) -> Vec<String> {
let marker = format!("# treq-client-key:{fingerprint_sha256}");
let script = format!(
r#"#!/bin/sh
set -eu
FILE=/home/treq/.ssh/authorized_keys
if [ -f "$FILE" ]; then
MARKER="{marker}"
awk -v marker="$MARKER" '
$0 == marker {{ skip = 2; next }}
skip > 0 {{ skip--; next }}
{{ print }}
' "$FILE" > "$FILE.treq_tmp"
mv "$FILE.treq_tmp" "$FILE"
fi
echo "treq: authorized key removed"
"#
);
vec!["/bin/sh".to_string(), "-c".to_string(), script]
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn ca_trust_command_installs_trusted_user_ca_keys() {
let command = install_ca_trust_command("ssh-ed25519 AAAA... treq-ssh-ca");
assert_eq!(command[0], "/bin/sh");
assert!(command[2].contains("ssh-ed25519 AAAA... treq-ssh-ca"));
assert!(command[2].contains("TrustedUserCAKeys /etc/ssh/treq_ca.pub"));
}

#[test]
fn authorized_key_install_is_guarded_by_fingerprint_marker() {
let command = install_authorized_key_command("ssh-ed25519 AAAA... me", "SHA256:abc");
assert!(command[2].contains("# treq-client-key:SHA256:abc"));
assert!(command[2].contains("grep -qF"));
}

#[test]
fn authorized_key_removal_targets_the_same_marker() {
let command = remove_authorized_key_command("SHA256:abc");
assert!(command[2].contains("# treq-client-key:SHA256:abc"));
assert!(command[2].contains("skip = 2"));
}

#[test]
fn known_manifest_version_resolves() {
assert!(manifest_for_version(1).is_some());
Expand Down
48 changes: 48 additions & 0 deletions src-tauri/src/core/remote_control_plane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,54 @@ pub struct IssueCertificateResponse {
pub endpoint: SshEndpoint,
}

/// Direct existing-key auth alternative (PRD "Existing keys without
/// certificates"): installs or removes a registered public key from the
/// managed VM's `authorized_keys`. Both directions are idempotent and
/// auditable on the server side.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstallAuthorizedKeyRequest {
pub instance_id: String,
pub key_id: String,
pub idempotency_key: IdempotencyKey,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RemoveAuthorizedKeyRequest {
pub instance_id: String,
pub key_id: String,
pub idempotency_key: IdempotencyKey,
}

/// A recorded host-key rotation, per the PRD's "Reprovisioning may rotate
/// the host key" paragraph: old and new fingerprints, the generation the new
/// key was observed at, the provider resource it was scanned from, and who
/// initiated the transition that produced it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostKeyRotationRecord {
pub endpoint_id: String,
pub previous_fingerprint_sha256: Option<String>,
pub new_fingerprint_sha256: String,
pub generation: u64,
pub provider_resource_id: Option<String>,
pub initiating_principal: String,
pub rotated_at: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientKeyRecord {
pub id: String,
pub algorithm: String,
pub fingerprint_sha256: String,
pub comment: Option<String>,
pub created_at: String,
pub revoked_at: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ListClientKeysResponse {
pub keys: Vec<ClientKeyRecord>,
}

// -- User-managed endpoints ---------------------------------------------------

/// Registers a fully explicit user-owned VM endpoint. Every field is supplied
Expand Down
11 changes: 10 additions & 1 deletion supabase/functions/_shared/remote/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,16 @@ export type RemoteAuditEventType =
| "instance_delete_failed"
| "host_key_registered"
| "host_key_rotated"
| "readiness_stage_failed";
| "host_keyscan_failed"
| "readiness_stage_failed"
| "client_key_registered"
| "client_key_revoked"
| "certificate_issued"
| "certificate_issue_failed"
| "authorized_key_installed"
| "authorized_key_removed"
| "ca_trust_installed"
| "ca_trust_install_failed";

export async function recordAuditEvent(
supabase: SupabaseClient,
Expand Down
140 changes: 140 additions & 0 deletions supabase/functions/_shared/remote/client-key-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Storage helpers for remote_client_keys and remote_endpoint_authorized_keys,
// used by the remote-ssh-trust Edge Function. Only public key material and
// metadata are ever written here (PRD "Client key policy").

import type { SupabaseClient } from "@supabase/supabase-js";

export interface ClientKeyRow {
id: string;
owner_user_id: string;
public_key: string;
algorithm: string;
fingerprint_sha256: string;
comment: string | null;
created_at: string;
revoked_at: string | null;
}

export async function findClientKeyByFingerprint(
supabase: SupabaseClient,
ownerUserId: string,
fingerprintSha256: string,
): Promise<ClientKeyRow | null> {
const { data, error } = await supabase
.from("remote_client_keys")
.select("*")
.eq("owner_user_id", ownerUserId)
.eq("fingerprint_sha256", fingerprintSha256)
.maybeSingle();
if (error) throw new Error(`failed to look up client key: ${error.message}`);
return data as ClientKeyRow | null;
}

export async function insertClientKey(
supabase: SupabaseClient,
params: { ownerUserId: string; publicKey: string; algorithm: string; fingerprintSha256: string; comment: string | null },
): Promise<ClientKeyRow> {
const { data, error } = await supabase
.from("remote_client_keys")
.insert({
owner_user_id: params.ownerUserId,
public_key: params.publicKey,
algorithm: params.algorithm,
fingerprint_sha256: params.fingerprintSha256,
comment: params.comment,
})
.select()
.single();
if (error) throw new Error(`failed to register client key: ${error.message}`);
return data as ClientKeyRow;
}

export async function listClientKeys(supabase: SupabaseClient, ownerUserId: string): Promise<ClientKeyRow[]> {
const { data, error } = await supabase
.from("remote_client_keys")
.select("*")
.eq("owner_user_id", ownerUserId)
.order("created_at", { ascending: false });
if (error) throw new Error(`failed to list client keys: ${error.message}`);
return (data ?? []) as ClientKeyRow[];
}

// Ownership is enforced by the `owner_user_id` filter here, not by trusting
// the caller-supplied key id alone (PRD "Security requirements").
export async function getOwnedClientKey(
supabase: SupabaseClient,
ownerUserId: string,
keyId: string,
): Promise<ClientKeyRow | null> {
const { data, error } = await supabase
.from("remote_client_keys")
.select("*")
.eq("owner_user_id", ownerUserId)
.eq("id", keyId)
.maybeSingle();
if (error) throw new Error(`failed to read client key: ${error.message}`);
return data as ClientKeyRow | null;
}

export async function revokeClientKey(supabase: SupabaseClient, ownerUserId: string, keyId: string): Promise<void> {
const { error } = await supabase
.from("remote_client_keys")
.update({ revoked_at: new Date().toISOString() })
.eq("owner_user_id", ownerUserId)
.eq("id", keyId);
if (error) throw new Error(`failed to revoke client key: ${error.message}`);
}

export interface AuthorizedKeyRow {
id: string;
endpoint_id: string;
client_key_id: string;
installed_at: string;
removed_at: string | null;
}

export async function findActiveAuthorizedKey(
supabase: SupabaseClient,
endpointId: string,
clientKeyId: string,
): Promise<AuthorizedKeyRow | null> {
const { data, error } = await supabase
.from("remote_endpoint_authorized_keys")
.select("*")
.eq("endpoint_id", endpointId)
.eq("client_key_id", clientKeyId)
.is("removed_at", null)
.maybeSingle();
if (error) throw new Error(`failed to look up authorized key record: ${error.message}`);
return data as AuthorizedKeyRow | null;
}

export async function recordAuthorizedKeyInstalled(
supabase: SupabaseClient,
params: { ownerUserId: string; endpointId: string; clientKeyId: string },
): Promise<void> {
const { error } = await supabase.from("remote_endpoint_authorized_keys").upsert(
{
owner_user_id: params.ownerUserId,
endpoint_id: params.endpointId,
client_key_id: params.clientKeyId,
installed_at: new Date().toISOString(),
removed_at: null,
},
{ onConflict: "endpoint_id,client_key_id" },
);
if (error) throw new Error(`failed to record authorized key install: ${error.message}`);
}

export async function recordAuthorizedKeyRemoved(
supabase: SupabaseClient,
endpointId: string,
clientKeyId: string,
): Promise<void> {
const { error } = await supabase
.from("remote_endpoint_authorized_keys")
.update({ removed_at: new Date().toISOString() })
.eq("endpoint_id", endpointId)
.eq("client_key_id", clientKeyId);
if (error) throw new Error(`failed to record authorized key removal: ${error.message}`);
}
14 changes: 13 additions & 1 deletion supabase/functions/_shared/remote/instance-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,19 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { RegionCode, SizePreset } from "./catalog.ts";

export type OperationType = "provision" | "wake" | "reprovision" | "delete";
export type OperationType =
| "provision"
| "wake"
| "reprovision"
| "delete"
| "register_client_key"
| "revoke_client_key"
| "issue_certificate"
| "register_endpoint"
| "register_repository"
| "install_authorized_key"
| "remove_authorized_key"
| "keyscan_host_key";
export type OperationStatus = "pending" | "in_progress" | "succeeded" | "failed";

export interface OperationRow {
Expand Down
Loading
Loading