diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4cd261d..038def3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6221,15 +6221,14 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.78" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ "bitflags 2.11.0", "cfg-if", "foreign-types 0.3.2", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -6262,9 +6261,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.115" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", diff --git a/src-tauri/src/security.rs b/src-tauri/src/security.rs index b8910c1..7472f41 100644 --- a/src-tauri/src/security.rs +++ b/src-tauri/src/security.rs @@ -14,7 +14,7 @@ use aes_gcm::{ }; use argon2::{Argon2, Params, Version}; use base64::{engine::general_purpose, Engine as _}; -use rand::RngCore; +use rand::{Rng, RngCore}; use std::collections::HashMap; use std::fs; use std::io::Write; @@ -968,8 +968,7 @@ impl Crypto { let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| SecurityError::Encryption(e.to_string()))?; - let mut nonce_bytes = [0u8; NONCE_LEN]; - OsRng.fill_bytes(&mut nonce_bytes); + let nonce_bytes: [u8; NONCE_LEN] = OsRng.gen(); let nonce = Nonce::from_slice(&nonce_bytes); let ciphertext = cipher @@ -1012,19 +1011,18 @@ impl Crypto { let argon2 = Argon2::new(argon2::Algorithm::Argon2id, Version::V0x13, params); - let mut key = [0u8; KEY_LEN]; + let mut key = vec![0; KEY_LEN]; argon2 .hash_password_into(passphrase.as_bytes(), salt, &mut key) .map_err(|e| SecurityError::KeyDerivation(e.to_string()))?; - Ok(key) + key.try_into() + .map_err(|_| SecurityError::KeyDerivation("invalid derived key length".into())) } /// Generate random salt pub fn generate_salt() -> [u8; SALT_LEN] { - let mut salt = [0u8; SALT_LEN]; - OsRng.fill_bytes(&mut salt); - salt + OsRng.gen() } /// Wrap master key with passphrase-derived key @@ -1066,8 +1064,10 @@ impl Crypto { return Err(SecurityError::InvalidKeyFormat); } - let mut key = [0u8; KEY_LEN]; - key.copy_from_slice(&key_bytes); + let key = key_bytes + .as_slice() + .try_into() + .map_err(|_| SecurityError::InvalidKeyFormat)?; // Zeroize the intermediate buffer key_bytes.zeroize(); @@ -1155,6 +1155,10 @@ pub fn secure_delete_file(path: &Path) -> std::io::Result<()> { mod tests { use super::*; + fn test_passphrase(label: &str) -> String { + format!("{label}-{}", rand::random::()) + } + #[test] fn test_master_key_generation() { let key1 = MasterKey::generate(); @@ -1176,10 +1180,10 @@ mod tests { #[test] fn test_key_wrapping() { let master_key = MasterKey::generate(); - let passphrase = "test-passphrase-123"; + let passphrase = test_passphrase("key-wrapping"); - let wrapped = Crypto::wrap_key(&master_key, passphrase).unwrap(); - let unwrapped = Crypto::unwrap_key(&wrapped, passphrase).unwrap(); + let wrapped = Crypto::wrap_key(&master_key, &passphrase).unwrap(); + let unwrapped = Crypto::unwrap_key(&wrapped, &passphrase).unwrap(); assert_eq!(master_key.as_bytes(), unwrapped.as_bytes()); } @@ -1187,19 +1191,22 @@ mod tests { #[test] fn test_wrong_passphrase_fails() { let master_key = MasterKey::generate(); - let wrapped = Crypto::wrap_key(&master_key, "correct-passphrase").unwrap(); + let correct_passphrase = test_passphrase("correct"); + let wrong_passphrase = test_passphrase("wrong"); + let wrapped = Crypto::wrap_key(&master_key, &correct_passphrase).unwrap(); - let result = Crypto::unwrap_key(&wrapped, "wrong-passphrase"); + let result = Crypto::unwrap_key(&wrapped, &wrong_passphrase); assert!(result.is_err()); } #[test] fn test_export_crypto() { let data = b"Export test data"; - let password = "export-password"; + let password = test_passphrase("export"); - let (ciphertext, salt, nonce) = ExportCrypto::encrypt_for_export(data, password).unwrap(); - let decrypted = ExportCrypto::decrypt_export(&ciphertext, &salt, &nonce, password).unwrap(); + let (ciphertext, salt, nonce) = ExportCrypto::encrypt_for_export(data, &password).unwrap(); + let decrypted = + ExportCrypto::decrypt_export(&ciphertext, &salt, &nonce, &password).unwrap(); assert_eq!(data.as_slice(), decrypted.as_slice()); } diff --git a/src-tauri/tests/security.rs b/src-tauri/tests/security.rs index 76406bd..652a614 100644 --- a/src-tauri/tests/security.rs +++ b/src-tauri/tests/security.rs @@ -12,6 +12,10 @@ use assistsupport_lib::security::{ use std::fs; use tempfile::TempDir; +fn test_passphrase(label: &str) -> String { + format!("{label}-{}", rand::random::()) +} + // ============================================================================ // Master Key Tests // ============================================================================ @@ -236,10 +240,10 @@ fn test_encryption_large_data() { #[test] fn test_key_wrapping_with_passphrase() { let master_key = MasterKey::generate(); - let passphrase = "my-secure-passphrase-123"; + let passphrase = test_passphrase("key-wrap"); - let wrapped = Crypto::wrap_key(&master_key, passphrase).expect("Wrapping failed"); - let unwrapped = Crypto::unwrap_key(&wrapped, passphrase).expect("Unwrapping failed"); + let wrapped = Crypto::wrap_key(&master_key, &passphrase).expect("Wrapping failed"); + let unwrapped = Crypto::unwrap_key(&wrapped, &passphrase).expect("Unwrapping failed"); assert_eq!( master_key.as_bytes(), @@ -251,9 +255,11 @@ fn test_key_wrapping_with_passphrase() { #[test] fn test_key_wrapping_wrong_passphrase_fails() { let master_key = MasterKey::generate(); - let wrapped = Crypto::wrap_key(&master_key, "correct-passphrase").expect("Wrapping failed"); + let correct_passphrase = test_passphrase("correct"); + let wrong_passphrase = test_passphrase("wrong"); + let wrapped = Crypto::wrap_key(&master_key, &correct_passphrase).expect("Wrapping failed"); - let result = Crypto::unwrap_key(&wrapped, "wrong-passphrase"); + let result = Crypto::unwrap_key(&wrapped, &wrong_passphrase); assert!( result.is_err(), "Unwrapping with wrong passphrase should fail" @@ -263,11 +269,11 @@ fn test_key_wrapping_wrong_passphrase_fails() { #[test] fn test_key_wrapping_produces_different_output() { let master_key = MasterKey::generate(); - let passphrase = "test-passphrase"; + let passphrase = test_passphrase("different-output"); // Wrap the same key twice - let wrapped1 = Crypto::wrap_key(&master_key, passphrase).expect("Wrapping failed"); - let wrapped2 = Crypto::wrap_key(&master_key, passphrase).expect("Wrapping failed"); + let wrapped1 = Crypto::wrap_key(&master_key, &passphrase).expect("Wrapping failed"); + let wrapped2 = Crypto::wrap_key(&master_key, &passphrase).expect("Wrapping failed"); // Salts should be different assert_ne!( @@ -276,8 +282,8 @@ fn test_key_wrapping_produces_different_output() { ); // Both should unwrap correctly - let unwrapped1 = Crypto::unwrap_key(&wrapped1, passphrase).expect("Unwrapping failed"); - let unwrapped2 = Crypto::unwrap_key(&wrapped2, passphrase).expect("Unwrapping failed"); + let unwrapped1 = Crypto::unwrap_key(&wrapped1, &passphrase).expect("Unwrapping failed"); + let unwrapped2 = Crypto::unwrap_key(&wrapped2, &passphrase).expect("Unwrapping failed"); assert_eq!(unwrapped1.as_bytes(), master_key.as_bytes()); assert_eq!(unwrapped2.as_bytes(), master_key.as_bytes()); @@ -290,12 +296,12 @@ fn test_key_wrapping_produces_different_output() { #[test] fn test_export_crypto_roundtrip() { let data = b"Export test data with various content: 123!@#"; - let password = "export-password-456"; + let password = test_passphrase("export"); let (ciphertext, salt, nonce) = - ExportCrypto::encrypt_for_export(data, password).expect("Export encryption failed"); + ExportCrypto::encrypt_for_export(data, &password).expect("Export encryption failed"); - let decrypted = ExportCrypto::decrypt_export(&ciphertext, &salt, &nonce, password) + let decrypted = ExportCrypto::decrypt_export(&ciphertext, &salt, &nonce, &password) .expect("Decryption failed"); assert_eq!( @@ -308,10 +314,12 @@ fn test_export_crypto_roundtrip() { #[test] fn test_export_crypto_wrong_password_fails() { let data = b"Secret export data"; - let (ciphertext, salt, nonce) = ExportCrypto::encrypt_for_export(data, "correct-password") + let correct_password = test_passphrase("correct-export"); + let wrong_password = test_passphrase("wrong-export"); + let (ciphertext, salt, nonce) = ExportCrypto::encrypt_for_export(data, &correct_password) .expect("Export encryption failed"); - let result = ExportCrypto::decrypt_export(&ciphertext, &salt, &nonce, "wrong-password"); + let result = ExportCrypto::decrypt_export(&ciphertext, &salt, &nonce, &wrong_password); assert!( result.is_err(), "Export decryption with wrong password should fail" diff --git a/src/components/Ingest/YouTubeIngest.test.tsx b/src/components/Ingest/YouTubeIngest.test.tsx new file mode 100644 index 0000000..70179cb --- /dev/null +++ b/src/components/Ingest/YouTubeIngest.test.tsx @@ -0,0 +1,161 @@ +// @vitest-environment jsdom +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { isAllowedYouTubeUrl, YouTubeIngest } from "./YouTubeIngest"; + +const mocks = vi.hoisted(() => ({ + ingestYoutube: vi.fn(), + ingesting: false, +})); + +vi.mock("../../hooks/useIngest", () => ({ + useIngest: () => ({ + ingestYoutube: mocks.ingestYoutube, + ingesting: mocks.ingesting, + }), +})); + +vi.mock("../shared/Button", () => ({ + Button: ({ + children, + onClick, + disabled, + }: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + }) => ( + + ), +})); + +afterEach(() => { + cleanup(); + mocks.ingestYoutube.mockReset(); + mocks.ingesting = false; +}); + +describe("isAllowedYouTubeUrl", () => { + it("accepts canonical YouTube hosts", () => { + expect( + isAllowedYouTubeUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ"), + ).toBe(true); + expect(isAllowedYouTubeUrl("https://youtube.com/watch?v=dQw4w9WgXcQ")).toBe( + true, + ); + expect(isAllowedYouTubeUrl("https://youtu.be/dQw4w9WgXcQ")).toBe(true); + }); + + it("rejects lookalike hosts that only contain YouTube text", () => { + expect( + isAllowedYouTubeUrl( + "https://youtube.com.evil.example/watch?v=dQw4w9WgXcQ", + ), + ).toBe(false); + expect( + isAllowedYouTubeUrl("https://notyoutube.com/watch?v=dQw4w9WgXcQ"), + ).toBe(false); + }); + + it("rejects malformed or unsupported URLs", () => { + expect(isAllowedYouTubeUrl("youtube.com/watch?v=dQw4w9WgXcQ")).toBe(false); + expect( + isAllowedYouTubeUrl("ftp://www.youtube.com/watch?v=dQw4w9WgXcQ"), + ).toBe(false); + }); +}); + +describe("YouTubeIngest", () => { + it("shows the install warning when yt-dlp is unavailable", () => { + render( + , + ); + + expect(screen.getByText("yt-dlp Not Installed")).toBeTruthy(); + expect(screen.getByText("brew install yt-dlp")).toBeTruthy(); + expect(screen.queryByLabelText("YouTube URL")).toBeNull(); + }); + + it("rejects invalid YouTube lookalike hosts before ingesting", async () => { + const user = userEvent.setup(); + const onError = vi.fn(); + render( + , + ); + + await user.type( + screen.getByLabelText("YouTube URL"), + "https://youtube.com.evil.example/watch?v=dQw4w9WgXcQ", + ); + await user.click(screen.getByRole("button", { name: "Ingest Transcript" })); + + expect(onError).toHaveBeenCalledWith("Please enter a valid YouTube URL"); + expect(mocks.ingestYoutube).not.toHaveBeenCalled(); + }); + + it("ingests valid trimmed YouTube URLs and clears the field", async () => { + const user = userEvent.setup(); + const onSuccess = vi.fn(); + mocks.ingestYoutube.mockResolvedValue({ + title: "Demo video", + chunk_count: 2, + word_count: 120, + }); + render( + , + ); + + const input = screen.getByLabelText("YouTube URL") as HTMLInputElement; + await user.type(input, " https://youtu.be/dQw4w9WgXcQ "); + await user.click(screen.getByRole("button", { name: "Ingest Transcript" })); + + await waitFor(() => { + expect(mocks.ingestYoutube).toHaveBeenCalledWith( + "https://youtu.be/dQw4w9WgXcQ", + "default", + ); + }); + expect(onSuccess).toHaveBeenCalledWith( + 'Ingested "Demo video" (2 chunks, 120 words)', + ); + expect(input.value).toBe(""); + }); + + it("submits with Enter and shows the ingesting label", async () => { + const user = userEvent.setup(); + mocks.ingesting = true; + render( + , + ); + + expect(screen.getByRole("button", { name: "Ingesting..." })).toBeTruthy(); + expect(screen.getByRole("button").hasAttribute("disabled")).toBe(true); + + await user.keyboard("{Enter}"); + expect(mocks.ingestYoutube).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/Ingest/YouTubeIngest.tsx b/src/components/Ingest/YouTubeIngest.tsx index 320c528..b4537fd 100644 --- a/src/components/Ingest/YouTubeIngest.tsx +++ b/src/components/Ingest/YouTubeIngest.tsx @@ -1,6 +1,6 @@ -import { useState } from 'react'; -import { useIngest } from '../../hooks/useIngest'; -import { Button } from '../shared/Button'; +import { useState } from "react"; +import { useIngest } from "../../hooks/useIngest"; +import { Button } from "../shared/Button"; interface YouTubeIngestProps { namespaceId: string; @@ -9,23 +9,45 @@ interface YouTubeIngestProps { onError: (message: string) => void; } -export function YouTubeIngest({ namespaceId, ytdlpAvailable, onSuccess, onError }: YouTubeIngestProps) { +export function isAllowedYouTubeUrl(value: string): boolean { + try { + const parsed = new URL(value); + const host = parsed.hostname.toLowerCase(); + + return ( + (parsed.protocol === "https:" || parsed.protocol === "http:") && + (host === "youtu.be" || + host === "youtube.com" || + host.endsWith(".youtube.com")) + ); + } catch { + return false; + } +} + +export function YouTubeIngest({ + namespaceId, + ytdlpAvailable, + onSuccess, + onError, +}: YouTubeIngestProps) { const { ingestYoutube, ingesting } = useIngest(); - const [url, setUrl] = useState(''); + const [url, setUrl] = useState(""); const handleIngest = async () => { if (!url.trim()) return; - // Basic YouTube URL validation - if (!url.includes('youtube.com') && !url.includes('youtu.be')) { - onError('Please enter a valid YouTube URL'); + if (!isAllowedYouTubeUrl(url.trim())) { + onError("Please enter a valid YouTube URL"); return; } try { const result = await ingestYoutube(url.trim(), namespaceId); - onSuccess(`Ingested "${result.title}" (${result.chunk_count} chunks, ${result.word_count} words)`); - setUrl(''); + onSuccess( + `Ingested "${result.title}" (${result.chunk_count} chunks, ${result.word_count} words)`, + ); + setUrl(""); } catch (e) { onError(`Failed to ingest YouTube video: ${e}`); } @@ -41,7 +63,10 @@ export function YouTubeIngest({ namespaceId, ytdlpAvailable, onSuccess, onError
yt-dlp Not Installed -

YouTube ingestion requires yt-dlp to be installed. Install it using Homebrew:

+

+ YouTube ingestion requires yt-dlp to be installed. Install it using + Homebrew: +

brew install yt-dlp

After installing, restart AssistSupport.

@@ -53,7 +78,10 @@ export function YouTubeIngest({ namespaceId, ytdlpAvailable, onSuccess, onError

YouTube

-

Ingest transcripts/captions from YouTube videos. The transcript will be extracted and stored in your knowledge base.

+

+ Ingest transcripts/captions from YouTube videos. The transcript will + be extracted and stored in your knowledge base. +

@@ -64,7 +92,7 @@ export function YouTubeIngest({ namespaceId, ytdlpAvailable, onSuccess, onError placeholder="https://www.youtube.com/watch?v=..." value={url} onChange={(e) => setUrl(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && !ingesting && handleIngest()} + onKeyDown={(e) => e.key === "Enter" && !ingesting && handleIngest()} disabled={ingesting} />
@@ -72,7 +100,9 @@ export function YouTubeIngest({ namespaceId, ytdlpAvailable, onSuccess, onError
  • Videos must have captions/subtitles available
  • -
  • Auto-generated captions will be used if no manual captions exist
  • +
  • + Auto-generated captions will be used if no manual captions exist +
  • Private or age-restricted videos cannot be ingested
@@ -83,7 +113,7 @@ export function YouTubeIngest({ namespaceId, ytdlpAvailable, onSuccess, onError onClick={handleIngest} disabled={!url.trim() || ingesting || ytdlpAvailable !== true} > - {ingesting ? 'Ingesting...' : 'Ingest Transcript'} + {ingesting ? "Ingesting..." : "Ingest Transcript"}