diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml new file mode 100644 index 0000000..189ea38 --- /dev/null +++ b/.github/workflows/pr-validation.yml @@ -0,0 +1,72 @@ +name: PR Validation + +on: + pull_request: + branches: [main, master] + push: + branches: [main, master] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Run Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo index + uses: actions/cache@v4 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libclang-dev \ + build-essential \ + libreadline-dev \ + zlib1g-dev \ + flex \ + bison \ + libxml2-dev \ + libxslt-dev \ + libssl-dev \ + libxml2-utils \ + xsltproc \ + pkg-config + + - name: Install pgrx + run: cargo install --locked cargo-pgrx + + - name: Initialize pgrx (download PostgreSQL binaries) + run: cargo pgrx init --pg18 download + + - name: Run tests with mock Vault + run: cargo pgrx test pg18 + + - name: Check formatting + run: cargo fmt -- --check + + - name: Run clippy + run: cargo clippy --no-default-features --features pg18 -- -D warnings diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..20a5dc9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,113 @@ +name: Release Docker Image + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: 'Docker image tag' + required: true + default: 'latest' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + name: Build and Push Multi-Arch Docker Image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=ref,event=branch + type=sha + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + INCLUDE_DEMO_INIT=false + cache-from: type=gha + cache-to: type=gha,mode=max + + build-demo-image: + name: Build Demo Docker Image with Init Script + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}}-demo + type=semver,pattern={{major}}.{{minor}}-demo + type=ref,event=branch,suffix=-demo + type=raw,value=demo + + - name: Build and push Demo Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + INCLUDE_DEMO_INIT=true + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index 5131896..ce9c8e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,6 +56,9 @@ RUN cargo pgrx package --pg-config /usr/local/pgsql/bin/pg_config --features pg1 # Runtime stage FROM postgres:18.1 +# Build argument to include demo init script +ARG INCLUDE_DEMO_INIT=false + # Install runtime dependencies RUN apt-get update && apt-get install -y \ libssl3 \ @@ -66,8 +69,14 @@ RUN apt-get update && apt-get install -y \ COPY --from=builder /build/target/release/pg_pii_vault-pg18/usr/local/pgsql/share/extension/* /usr/share/postgresql/18/extension/ COPY --from=builder /build/target/release/pg_pii_vault-pg18/usr/local/pgsql/lib/* /usr/lib/postgresql/18/lib/ -# Copy initialization script -COPY docker-init.sql /docker-entrypoint-initdb.d/ +# Conditionally copy initialization script for demo purposes only +RUN if [ "$INCLUDE_DEMO_INIT" = "true" ]; then mkdir -p /docker-entrypoint-initdb.d/; fi +COPY --chmod=0755 docker-init.sql /tmp/docker-init.sql +RUN if [ "$INCLUDE_DEMO_INIT" = "true" ]; then \ + mv /tmp/docker-init.sql /docker-entrypoint-initdb.d/; \ + else \ + rm /tmp/docker-init.sql; \ + fi # Environment variables for Vault connection ENV PII_VAULT_URL="" diff --git a/README.md b/README.md index 662abf2..57e05dd 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,46 @@ PostgreSQL extension for GDPR-compliant column-level encryption using HashiCorp ## Quick Start -### Installation +### Docker Compose Demo + +The fastest way to try `pg_pii_vault` is using Docker Compose: + +```bash +# Start all services (PostgreSQL + Vault) +docker-compose up -d + +# Wait for services to be ready +docker-compose ps + +# Connect to PostgreSQL +psql -h localhost -U postgres -d testdb +``` + +The demo environment includes: +- PostgreSQL 18 with `pg_pii_vault` extension pre-installed +- HashiCorp Vault in dev mode with Transit engine enabled +- Pre-configured connection settings +- Sample database initialization + +### Production Installation + +#### Option 1: Docker Image + +```bash +# Pull the production image (without demo init script) +docker pull ghcr.io/g0ddest/pg_pii_vault:latest + +# Run with your Vault configuration +docker run -d \ + -e POSTGRES_PASSWORD=yourpassword \ + -e PII_VAULT_URL=http://vault:8200 \ + -e PII_VAULT_TOKEN=your-vault-token \ + -e PII_VAULT_MOUNT=transit \ + -p 5432:5432 \ + ghcr.io/g0ddest/pg_pii_vault:latest +``` + +#### Option 2: Build from Source ```bash # Prerequisites: Rust, pgrx @@ -132,6 +171,32 @@ SELECT piitext_out_text(secret) FROM users WHERE id = 456; - **IV**: 12 bytes, generated via `pg_strong_random()` - **AAD**: `col:piitext:id:` for protection against attacks +## Distribution + +### Docker Images + +Docker images are automatically built for multiple architectures on each release: + +- **Production Image**: `ghcr.io/g0ddest/pg_pii_vault:latest` + - PostgreSQL 18 with `pg_pii_vault` extension + - No demo initialization scripts + - Multi-arch: `linux/amd64`, `linux/arm64` + +- **Demo Image**: `ghcr.io/g0ddest/pg_pii_vault:demo` + - Includes sample database setup + - For testing and demonstration purposes only + - Multi-arch: `linux/amd64`, `linux/arm64` + +### Building Custom Images + +```bash +# Build production image +docker build --build-arg INCLUDE_DEMO_INIT=false -t pg_pii_vault:prod . + +# Build demo image +docker build --build-arg INCLUDE_DEMO_INIT=true -t pg_pii_vault:demo . +``` + ## Testing ```bash @@ -139,10 +204,12 @@ SELECT piitext_out_text(secret) FROM users WHERE id = 456; cargo pgrx test pg16 # Expected output: -# test tests::pg_test_piitext_basic ... ok -# test tests::pg_test_encryption_with_uuid ... ok -# test tests::pg_test_encryption_with_int ... ok -# test tests::pg_test_debug_output ... ok +# test tests::test_piitext_basic ... ok +# test tests::test_encryption_with_uuid ... ok +# test tests::test_encryption_with_int ... ok +# test tests::test_debug_output ... ok +# test tests::test_crypto_shredding_workflow ... ok +# test tests::test_re_encryption_with_different_key ... ok ``` ## Configuration @@ -183,6 +250,14 @@ For GDPR "right to be forgotten": 2. SELECT without `piitext_out_text()` returns CBOR JSON 3. Triggers not implemented due to pgrx limitations +## CI/CD + +The project uses GitHub Actions for continuous integration and delivery: + +- **PR Validation**: Runs tests, formatting checks, and clippy on every pull request +- **Release Pipeline**: Builds multi-architecture Docker images on release creation +- **Automated Testing**: All tests run using mock Vault for faster execution + ## Roadmap - [ ] Automatic encryption triggers diff --git a/docker-compose.yml b/docker-compose.yml index f15b1ff..b82ad30 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,6 +44,8 @@ services: build: context: . dockerfile: Dockerfile + args: + INCLUDE_DEMO_INIT: "true" container_name: postgres_pii_vault ports: - "5432:5432" diff --git a/src/bin/pgrx_embed.rs b/src/bin/pgrx_embed.rs index 57483f1..5f5c4d8 100644 --- a/src/bin/pgrx_embed.rs +++ b/src/bin/pgrx_embed.rs @@ -1 +1 @@ -::pgrx::pgrx_embed!(); \ No newline at end of file +::pgrx::pgrx_embed!(); diff --git a/src/cache.rs b/src/cache.rs index 33a72b0..2787e40 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -8,9 +8,8 @@ struct CacheEntry { expires_at: Instant, } -static KEY_CACHE: Lazy, CacheEntry>>> = Lazy::new(|| { - RwLock::new(HashMap::new()) -}); +static KEY_CACHE: Lazy, CacheEntry>>> = + Lazy::new(|| RwLock::new(HashMap::new())); pub fn get_cached_key(key_id: &[u8]) -> Option<[u8; 32]> { let cache = KEY_CACHE.read().ok()?; @@ -24,9 +23,12 @@ pub fn get_cached_key(key_id: &[u8]) -> Option<[u8; 32]> { pub fn insert_into_cache(key_id: Vec, key: [u8; 32], ttl_secs: u64) { if let Ok(mut cache) = KEY_CACHE.write() { - cache.insert(key_id, CacheEntry { - key, - expires_at: Instant::now() + Duration::from_secs(ttl_secs), - }); + cache.insert( + key_id, + CacheEntry { + key, + expires_at: Instant::now() + Duration::from_secs(ttl_secs), + }, + ); } } diff --git a/src/contents.rs b/src/contents.rs index 6e4c18e..8482814 100644 --- a/src/contents.rs +++ b/src/contents.rs @@ -36,7 +36,9 @@ impl<'a> From> for Vec { fn from(contents: PiiTextContents<'a>) -> Vec { match contents { PiiTextContents::Staging(s) => s.as_bytes().to_vec(), - PiiTextContents::Sealed(data) => serde_cbor::to_vec(&data).expect("CBOR serialization failed"), + PiiTextContents::Sealed(data) => { + serde_cbor::to_vec(&data).expect("CBOR serialization failed") + } } } } @@ -45,7 +47,9 @@ impl<'a> From<&PiiTextContents<'a>> for Vec { fn from(contents: &PiiTextContents<'a>) -> Vec { match contents { PiiTextContents::Staging(s) => s.as_bytes().to_vec(), - PiiTextContents::Sealed(data) => serde_cbor::to_vec(data).expect("CBOR serialization failed"), + PiiTextContents::Sealed(data) => { + serde_cbor::to_vec(data).expect("CBOR serialization failed") + } } } } diff --git a/src/crypto.rs b/src/crypto.rs index c81c3d6..f41d7e3 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -17,7 +17,7 @@ pub fn encrypt( return Err("Failed to generate random IV".to_string()); } } - + let nonce = Nonce::from_slice(&iv_bytes); let payload = Payload { msg: plaintext.as_bytes(), @@ -43,14 +43,10 @@ pub fn encrypt( }) } -pub fn decrypt( - data: &PiiSealedData, - key: &[u8; 32], - context: &str, -) -> Result { +pub fn decrypt(data: &PiiSealedData, key: &[u8; 32], context: &str) -> Result { let cipher = Aes256Gcm::new(key.into()); let nonce = Nonce::from_slice(&data.iv); - + let mut ciphertext_with_tag = data.ciphertext.clone(); ciphertext_with_tag.extend_from_slice(&data.tag); diff --git a/src/lib.rs b/src/lib.rs index eb029a1..0ef944a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,13 +2,12 @@ use pgrx::guc::{GucContext, GucFlags, GucRegistry, GucSetting}; use pgrx::prelude::*; use serde::{Deserialize, Serialize}; use std::borrow::Cow; -use std::ffi::CStr; use std::ffi::CString; +mod cache; mod contents; mod crypto; mod vault; -mod cache; use contents::PiiTextContents; static PII_VAULT_URL: GucSetting> = GucSetting::>::new(None); @@ -21,33 +20,33 @@ static PII_VAULT_CACHE_TTL: GucSetting = GucSetting::::new(300); #[pg_guard] pub unsafe extern "C-unwind" fn _PG_init() { GucRegistry::define_string_guc( - CStr::from_bytes_with_nul_unchecked(b"pii_vault.url\0"), - CStr::from_bytes_with_nul_unchecked(b"Vault server URL\0"), - CStr::from_bytes_with_nul_unchecked(b"URL of the Hashicorp Vault server\0"), + c"pii_vault.url", + c"Vault server URL", + c"URL of the Hashicorp Vault server", &PII_VAULT_URL, GucContext::Userset, GucFlags::default(), ); GucRegistry::define_string_guc( - CStr::from_bytes_with_nul_unchecked(b"pii_vault.token\0"), - CStr::from_bytes_with_nul_unchecked(b"Vault token\0"), - CStr::from_bytes_with_nul_unchecked(b"Authentication token for Vault\0"), + c"pii_vault.token", + c"Vault token", + c"Authentication token for Vault", &PII_VAULT_TOKEN, GucContext::Userset, GucFlags::default(), ); GucRegistry::define_string_guc( - CStr::from_bytes_with_nul_unchecked(b"pii_vault.mount\0"), - CStr::from_bytes_with_nul_unchecked(b"Vault Transit Mount\0"), - CStr::from_bytes_with_nul_unchecked(b"Mount path for the Transit engine\0"), + c"pii_vault.mount", + c"Vault Transit Mount", + c"Mount path for the Transit engine", &PII_VAULT_MOUNT, GucContext::Userset, GucFlags::default(), ); GucRegistry::define_int_guc( - CStr::from_bytes_with_nul_unchecked(b"pii_vault.cache_ttl_sec\0"), - CStr::from_bytes_with_nul_unchecked(b"Cache TTL\0"), - CStr::from_bytes_with_nul_unchecked(b"Time to live for cached keys in seconds\0"), + c"pii_vault.cache_ttl_sec", + c"Cache TTL", + c"Time to live for cached keys in seconds", &PII_VAULT_CACHE_TTL, 0, i32::MAX, @@ -86,13 +85,15 @@ fn piitext_output(input: PiiText) -> String { let key = if is_mock { Some([0u8; 32]) } else { - cache::get_cached_key(&sealed.key_id) - .or_else(|| { - vault::get_key_from_vault(&sealed.key_id).ok().map(|k| { - cache::insert_into_cache(sealed.key_id.clone(), k, PII_VAULT_CACHE_TTL.get() as u64); - k - }) + cache::get_cached_key(&sealed.key_id).or_else(|| { + vault::get_key_from_vault(&sealed.key_id).ok().inspect(|k| { + cache::insert_into_cache( + sealed.key_id.clone(), + *k, + PII_VAULT_CACHE_TTL.get() as u64, + ); }) + }) }; if let Some(k) = key { @@ -105,11 +106,15 @@ fn piitext_output(input: PiiText) -> String { } // Create implicit casts so piitext behaves like text -extension_sql!(r#" +extension_sql!( + r#" -- Make casts implicit so SELECT works naturally CREATE CAST (text AS piitext) WITH FUNCTION piitext_in_text(text) AS IMPLICIT; CREATE CAST (piitext AS text) WITH FUNCTION piitext_out_text(piitext) AS IMPLICIT; -"#, name = "piitext_casts", requires = [piitext_input, piitext_output]); +"#, + name = "piitext_casts", + requires = [piitext_input, piitext_output] +); #[pg_extern] fn piitext_debug(input: PiiText) -> String { @@ -136,25 +141,27 @@ fn piitext_encrypt(plaintext: &str, key_id_bytes: Vec) -> PiiText { } else { match cache::get_cached_key(&key_id_bytes) { Some(k) => k, - None => { - match vault::get_key_from_vault(&key_id_bytes) { - Ok(k) => { - cache::insert_into_cache(key_id_bytes.clone(), k, PII_VAULT_CACHE_TTL.get() as u64); - k - } - Err(e) => { - pgrx::error!("Vault error: {}", e); - } + None => match vault::get_key_from_vault(&key_id_bytes) { + Ok(k) => { + cache::insert_into_cache( + key_id_bytes.clone(), + k, + PII_VAULT_CACHE_TTL.get() as u64, + ); + k } - } + Err(e) => { + pgrx::error!("Vault error: {}", e); + } + }, } }; let context = format!("col:piitext:id:{}", hex::encode(&key_id_bytes)); match crypto::encrypt(plaintext, &key, &key_id_bytes, &context) { - Ok(sealed) => { - PiiText { inner: PiiTextContents::Sealed(sealed).into() } - } + Ok(sealed) => PiiText { + inner: PiiTextContents::Sealed(sealed).into(), + }, Err(e) => { pgrx::error!("Encryption failed: {}", e); } @@ -180,24 +187,24 @@ fn piitext_encrypt_from_piitext(input: PiiText, key_id_bytes: Vec) -> PiiTex let key = if is_mock { Some([0u8; 32]) } else { - cache::get_cached_key(&sealed.key_id) - .or_else(|| { - vault::get_key_from_vault(&sealed.key_id).ok().map(|k| { - cache::insert_into_cache(sealed.key_id.clone(), k, PII_VAULT_CACHE_TTL.get() as u64); - k - }) + cache::get_cached_key(&sealed.key_id).or_else(|| { + vault::get_key_from_vault(&sealed.key_id).ok().inspect(|k| { + cache::insert_into_cache( + sealed.key_id.clone(), + *k, + PII_VAULT_CACHE_TTL.get() as u64, + ); }) + }) }; match key { - Some(k) => { - match crypto::decrypt(&sealed, &k, &context) { - Ok(p) => p, - Err(e) => { - pgrx::error!("Decryption failed during re-encryption: {}", e); - } + Some(k) => match crypto::decrypt(&sealed, &k, &context) { + Ok(p) => p, + Err(e) => { + pgrx::error!("Decryption failed during re-encryption: {}", e); } - } + }, None => { pgrx::error!("Key not found for decryption during re-encryption"); } @@ -217,7 +224,7 @@ mod tests { #[pg_test] fn test_piitext_basic() { - // Базовый тест конвертации текста + // Basic text conversion test let res = Spi::get_one::<&str>("SELECT piitext_out_text(piitext_in_text('hello'))") .expect("SPI failed") .expect("Result is null"); @@ -228,15 +235,15 @@ mod tests { fn test_encryption_with_uuid() { Spi::run("SET pii_vault.url = 'mock://localhost';").unwrap(); - // Шифруем данные с UUID как ключом (16 bytes) - // Используем decode для создания bytea из hex + // Encrypt data with UUID as key (16 bytes) + // Use decode to create bytea from hex let encrypted = Spi::get_one::( "SELECT piitext_encrypt('my secret', decode('a0eebc999c0b4ef8bb6d6bb9bd380a11', 'hex'))", ) .expect("SPI failed") .expect("Result is null"); - // Расшифровываем обратно + // Decrypt back let decrypted = piitext_output(encrypted); assert_eq!(decrypted, "my secret"); } @@ -245,14 +252,14 @@ mod tests { fn test_encryption_with_int() { Spi::run("SET pii_vault.url = 'mock://localhost';").unwrap(); - // Шифруем данные с integer как ключом (123 в big-endian = 0x0000007b) + // Encrypt data with integer as key (123 in big-endian = 0x0000007b) let encrypted = Spi::get_one::( "SELECT piitext_encrypt('int secret', decode('0000007b', 'hex'))", ) .expect("SPI failed") .expect("Result is null"); - // Расшифровываем обратно + // Decrypt back let decrypted = piitext_output(encrypted); assert_eq!(decrypted, "int secret"); } @@ -268,7 +275,7 @@ mod tests { .expect("Result is null"); let debug = piitext_debug(encrypted); - // Проверяем что это Sealed структура + // Verify this is a Sealed structure assert!(debug.contains("Sealed")); assert!(debug.contains("version: 1")); assert!(debug.contains("key_id")); @@ -286,24 +293,27 @@ mod tests { Spi::run("INSERT INTO users_test VALUES (123, piitext_in_text('secret text'));").unwrap(); // Step 2: Verify plain text is readable - let plain_result = Spi::get_one::<&str>("SELECT piitext_out_text(secret) FROM users_test WHERE id = 123;") - .expect("SPI failed") - .expect("Result is null"); + let plain_result = + Spi::get_one::<&str>("SELECT piitext_out_text(secret) FROM users_test WHERE id = 123;") + .expect("SPI failed") + .expect("Result is null"); assert_eq!(plain_result, "secret text"); // Step 3: Encrypt in place with key_id Spi::run("UPDATE users_test SET secret = piitext_encrypt_piitext(secret, decode('0000007b', 'hex')) WHERE id = 123;").unwrap(); // Step 4: Verify encrypted data is still readable (auto-decrypt) - let encrypted_result = Spi::get_one::<&str>("SELECT piitext_out_text(secret) FROM users_test WHERE id = 123;") - .expect("SPI failed") - .expect("Result is null"); + let encrypted_result = + Spi::get_one::<&str>("SELECT piitext_out_text(secret) FROM users_test WHERE id = 123;") + .expect("SPI failed") + .expect("Result is null"); assert_eq!(encrypted_result, "secret text"); // Step 5: Verify data is actually encrypted (not staging) - let debug_result = Spi::get_one::<&str>("SELECT piitext_debug(secret) FROM users_test WHERE id = 123;") - .expect("SPI failed") - .expect("Result is null"); + let debug_result = + Spi::get_one::<&str>("SELECT piitext_debug(secret) FROM users_test WHERE id = 123;") + .expect("SPI failed") + .expect("Result is null"); assert!(debug_result.contains("Sealed")); assert!(debug_result.contains("key_id")); @@ -321,24 +331,27 @@ mod tests { Spi::run("INSERT INTO reencrypt_test VALUES (1, piitext_encrypt('sensitive data', decode('00000001', 'hex')));").unwrap(); // Verify first encryption - let debug1 = Spi::get_one::<&str>("SELECT piitext_debug(data) FROM reencrypt_test WHERE id = 1;") - .expect("SPI failed") - .expect("Result is null"); + let debug1 = + Spi::get_one::<&str>("SELECT piitext_debug(data) FROM reencrypt_test WHERE id = 1;") + .expect("SPI failed") + .expect("Result is null"); assert!(debug1.contains("key_id: [0, 0, 0, 1]")); // Re-encrypt with second key Spi::run("UPDATE reencrypt_test SET data = piitext_encrypt_piitext(data, decode('00000002', 'hex')) WHERE id = 1;").unwrap(); // Verify second encryption - let debug2 = Spi::get_one::<&str>("SELECT piitext_debug(data) FROM reencrypt_test WHERE id = 1;") - .expect("SPI failed") - .expect("Result is null"); + let debug2 = + Spi::get_one::<&str>("SELECT piitext_debug(data) FROM reencrypt_test WHERE id = 1;") + .expect("SPI failed") + .expect("Result is null"); assert!(debug2.contains("key_id: [0, 0, 0, 2]")); // Verify plaintext is still the same - let decrypted = Spi::get_one::<&str>("SELECT piitext_out_text(data) FROM reencrypt_test WHERE id = 1;") - .expect("SPI failed") - .expect("Result is null"); + let decrypted = + Spi::get_one::<&str>("SELECT piitext_out_text(data) FROM reencrypt_test WHERE id = 1;") + .expect("SPI failed") + .expect("Result is null"); assert_eq!(decrypted, "sensitive data"); // Cleanup @@ -348,13 +361,10 @@ mod tests { #[cfg(test)] pub mod pg_test { - pub fn setup(_options: Vec<&str>) { - } + pub fn setup(_options: Vec<&str>) {} #[must_use] pub fn postgresql_conf_options() -> Vec<&'static str> { - vec![ - "pii_vault.url = 'mock://localhost'", - ] + vec!["pii_vault.url = 'mock://localhost'"] } } diff --git a/src/vault.rs b/src/vault.rs index 818e545..35caae8 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -1,6 +1,6 @@ -use serde::Deserialize; -use crate::{PII_VAULT_URL, PII_VAULT_TOKEN, PII_VAULT_MOUNT}; +use crate::{PII_VAULT_MOUNT, PII_VAULT_TOKEN, PII_VAULT_URL}; use base64::{engine::general_purpose, Engine as _}; +use serde::Deserialize; #[derive(Deserialize)] struct VaultExportResponse { @@ -17,8 +17,12 @@ pub fn get_key_from_vault(key_id: &[u8]) -> Result<[u8; 32], String> { let token_guc = PII_VAULT_TOKEN.get().ok_or("pii_vault.token is not set")?; let mount_guc = PII_VAULT_MOUNT.get(); - let url = url_guc.to_str().map_err(|e: std::str::Utf8Error| e.to_string())?; - let token = token_guc.to_str().map_err(|e: std::str::Utf8Error| e.to_string())?; + let url = url_guc + .to_str() + .map_err(|e: std::str::Utf8Error| e.to_string())?; + let token = token_guc + .to_str() + .map_err(|e: std::str::Utf8Error| e.to_string())?; let mount_str; let mount = match &mount_guc { Some(m) => { @@ -32,7 +36,8 @@ pub fn get_key_from_vault(key_id: &[u8]) -> Result<[u8; 32], String> { let full_url = format!("{}/v1/{}/export/encryption-key/{}", url, mount, key_name); let client = reqwest::blocking::Client::new(); - let resp = client.get(&full_url) + let resp = client + .get(&full_url) .header("X-Vault-Token", token) .send() .map_err(|e| format!("Vault request failed: {}", e))?; @@ -48,12 +53,21 @@ pub fn get_key_from_vault(key_id: &[u8]) -> Result<[u8; 32], String> { return Err(format!("Vault returned error: {}", resp.status())); } - let export_resp: VaultExportResponse = resp.json().map_err(|e| format!("Failed to parse Vault response: {}", e))?; - + let export_resp: VaultExportResponse = resp + .json() + .map_err(|e| format!("Failed to parse Vault response: {}", e))?; + // Transit export returns keys in a map, version as key - let latest_key_base64 = export_resp.data.keys.values().next().ok_or("No key found in Vault response")?; - let key_bytes = general_purpose::STANDARD.decode(latest_key_base64).map_err(|e| format!("Failed to decode key: {}", e))?; - + let latest_key_base64 = export_resp + .data + .keys + .values() + .next() + .ok_or("No key found in Vault response")?; + let key_bytes = general_purpose::STANDARD + .decode(latest_key_base64) + .map_err(|e| format!("Failed to decode key: {}", e))?; + if key_bytes.len() != 32 { return Err(format!("Invalid key length: {}", key_bytes.len())); } @@ -66,7 +80,8 @@ pub fn get_key_from_vault(key_id: &[u8]) -> Result<[u8; 32], String> { fn create_key_in_vault(url: &str, token: &str, mount: &str, key_name: &str) -> Result<(), String> { let full_url = format!("{}/v1/{}/keys/{}", url, mount, key_name); let client = reqwest::blocking::Client::new(); - let resp = client.post(&full_url) + let resp = client + .post(&full_url) .header("X-Vault-Token", token) .json(&serde_json::json!({ "type": "aes256-gcm96", @@ -76,7 +91,10 @@ fn create_key_in_vault(url: &str, token: &str, mount: &str, key_name: &str) -> R .map_err(|e| format!("Vault create key request failed: {}", e))?; if !resp.status().is_success() { - return Err(format!("Vault create key returned error: {}", resp.status())); + return Err(format!( + "Vault create key returned error: {}", + resp.status() + )); } Ok(()) }