diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77309f25..cd0059ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,9 @@ jobs: - name: Run checks run: make check + - name: Run security checks + run: make security-check + - name: Run vulnerability check uses: golang/govulncheck-action@v1 with: diff --git a/Makefile b/Makefile index 19741f56..c1947582 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: dev build fmt check-fmt test vet check clean +.PHONY: dev build fmt check-fmt test vet security-check check clean dev: mkdir -p ./tmp @@ -23,6 +23,10 @@ test: vet: go vet ./... +security-check: + go test -cover ./internal/crypto ./internal/security ./internal/userpresence + go run golang.org/x/vuln/cmd/govulncheck@v1.5.0 ./... + check: check-fmt test vet clean: diff --git a/README.md b/README.md index c2affbc5..82c14fd2 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,22 @@ be committed to git. Private device keys are stored outside the repository in the platform's native secret store when available, or in a restrictive file-backed identity store otherwise. +## Security Status + +Ghostable is beta security software and has not completed an external security +audit. The repository includes a public [security policy](SECURITY.md), a +[threat model](docs/security/threat-model.md), and stable +[test vectors](docs/security/test-vectors.md) so developers can inspect the +claims and reproduce the critical cryptographic checks. + +In Ghostable, "zero-knowledge" has a narrow product meaning: plaintext secret +values are encrypted locally before Ghostable writes repository-backed state. +Ghostable does not run a hosted service that receives plaintext project secrets. +This does not mean the local machine, local device identity, shell history, +plaintext `.env` files, deploy providers, or everyone with repository write +access are automatically trusted or harmless. Review `.ghostable/` changes with +the same care as code changes. + ## Install Install Ghostable once, then run it inside each project you want to manage. diff --git a/SECURITY.md b/SECURITY.md index d3be1def..54402e1f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,6 +13,28 @@ and are intended to be committed to git. Private device keys are stored outside the repository in the platform's native secret store when available, or in a restrictive file-backed identity store otherwise. +## Security Status + +Ghostable is beta software and has not completed an external security audit. +The current assurance evidence is repository-visible: focused security tests, +documented test vectors, and the public threat model in +`docs/security/threat-model.md`. Do not treat the current release as formally +audited or certified. + +The zero-knowledge claim is scoped to Ghostable's local-first storage model: +Ghostable does not run a hosted service that receives plaintext project secret +values, and committed Ghostable value records are encrypted locally before they +are written. The claim does not cover a compromised local device, plaintext +`.env` files created for local or deploy workflows, shell history, terminal +logs, third-party deploy providers, CI systems that receive decrypted values, or +malicious repository changes that are accepted by reviewers. + +Security-sensitive compatibility fixtures are documented in +`docs/security/test-vectors.md` and stored in +`docs/security/test-vectors.json`. Changes to cryptographic record handling +should update those fixtures only when the compatibility impact is deliberate +and reviewed. + ## Cryptographic Model - Device identity uses Ed25519 for signatures and X25519 for key exchange. @@ -35,6 +57,8 @@ and keeping repository metadata honest. - Revoke access for lost, retired, or compromised devices. - Review policy, device, and access grant changes with the same care as code changes. +- Treat CI tokens, deploy-provider credentials, and local plaintext env files as + out-of-band secrets that need their own controls. - Keep Ghostable updated so you receive security fixes and cryptographic model improvements. diff --git a/docs/security/test-vectors.json b/docs/security/test-vectors.json new file mode 100644 index 00000000..a8b479ee --- /dev/null +++ b/docs/security/test-vectors.json @@ -0,0 +1,39 @@ +{ + "canonicalJSON": { + "description": "Object keys are sorted and null fields are omitted before signing.", + "expected": "{\"a\":\"first\",\"items\":[\"one\",2],\"nested\":{\"a\":7,\"b\":true},\"z\":\"last\"}", + "input": { + "a": "first", + "empty": null, + "items": [ + "one", + 2 + ], + "nested": { + "a": 7, + "b": true + }, + "z": "last" + } + }, + "ed25519CanonicalSignature": { + "canonical": "{\"device_id\":\"dev_fixture\",\"name\":\"fixture\",\"schema\":\"ghostable.test.v1\"}", + "publicKeyB64": "JUO5L/EJVRFHatyDadtt3JM2ZaEZeN2hQE7hBmypVZ0=", + "seedB64": "QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl8=", + "signatureB64": "d4g++eoglLHSiSgtufRVRlXK8nW/3HUtWSQhb7eWO3ckVpKoiBgbpNGEq3NfMFGA6dn49nyhLt5Qdsvprf65Bg==" + }, + "hkdf": { + "encryptionKeyB64": "NCx0FwTh+ZudWBolAM0tS54H8tuWExu+znI6Af50HIo=", + "hmacKeyB64": "CLow0vzg42Z0G1DDnXzqGxg6aCIrV3czg5i3TMPMg2Q=", + "masterB64": "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=", + "scope": "ghostable/project-fixture/production" + }, + "version": "ghostable.security-test-vectors.v1", + "xchacha20poly1305": { + "aadB64": "eyJvcmciOiJnaG9zdGFibGUiLCJwcm9qZWN0IjoicHJvamVjdC1maXh0dXJlIiwiZW52IjoicHJvZHVjdGlvbiIsIm5hbWUiOiJBUFBfS0VZIn0=", + "ciphertextB64": "ezA1vw5FcaZL2zPPLTMsp1w5pFmT3RV88oVAnpviDHMNfrn8", + "keyB64": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "nonceB64": "ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3", + "plaintextB64": "Zml4dHVyZS1zZWNyZXQtdmFsdWU=" + } +} diff --git a/docs/security/test-vectors.md b/docs/security/test-vectors.md new file mode 100644 index 00000000..a1c5555e --- /dev/null +++ b/docs/security/test-vectors.md @@ -0,0 +1,28 @@ +# Security Test Vectors + +The fixture file `docs/security/test-vectors.json` contains deterministic +vectors for security-sensitive compatibility checks. These vectors are public +evidence for developers reviewing the beta security design; they are not an +external audit. + +The vectors cover: + +- Canonical JSON ordering and null-field omission before signing. +- HKDF-SHA256 value-key derivation for an environment scope. +- XChaCha20-Poly1305 decryption with explicit key, nonce, plaintext, and AAD. +- Ed25519 signatures over canonical Ghostable records. + +The focused unit tests load this fixture and assert that the current +implementation still matches it. If a vector changes, the pull request should +explain whether the change is intentional compatibility movement, a bug fix, or +only fixture maintenance. + +Run: + +```sh +go test -cover ./internal/crypto ./internal/security ./internal/userpresence +make security-check +``` + +The fixture intentionally exercises deterministic decrypt and verify paths. +Normal value and envelope writes still use random nonces and fresh keys. diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md new file mode 100644 index 00000000..65b6da42 --- /dev/null +++ b/docs/security/threat-model.md @@ -0,0 +1,87 @@ +# Ghostable Threat Model + +This threat model documents the current beta security design. It is not a +substitute for an external audit. + +## Assets + +- Plaintext environment values decrypted during local, CI, or deploy workflows. +- Local device identity material used for signatures and access grants. +- Environment keys and per-device access grants stored under `.ghostable/`. +- Signed policy, device, value, key metadata, suppression, and activity records. +- Repository history containing encrypted Ghostable state. + +## Trust Boundaries + +- Local machine boundary: private device identities live outside the repository + in the platform secret store when available, or in restrictive local files. +- Repository boundary: `.ghostable/` records are intended to be committed and + reviewed, but repository writers can propose policy, device, grant, and value + changes. +- Automation boundary: `GHOSTABLE_CI_TOKEN` credentials are scoped automation + secrets and are trusted only for their configured grants. +- Deploy-provider boundary: Forge, Vapor, Cloud, local `.env`, and process + injection workflows receive plaintext values after Ghostable decrypts them. + +## Attacker Model + +Ghostable is designed to resist: + +- Passive repository readers who can inspect committed `.ghostable/` files but + do not have a valid local identity, environment key, or automation credential. +- Accidental or malicious modification of signed repository metadata, value + records, device records, access grants, and policy records. +- Stale or revoked grants after environment key rotation when the repository + state is reviewed and up to date. +- Non-interactive local use of protected production-like environments without a + scoped automation credential. + +Ghostable is not designed to fully resist: + +- A compromised local device that can read local identity material or decrypted + process memory. +- A compromised terminal, shell history, editor, deploy provider, CI runner, or + local plaintext `.env` file after values are decrypted. +- Reviewers accepting malicious repository changes to policy, devices, grants, + or encrypted records. +- Users placing secrets in plaintext metadata such as annotations, schema + descriptions, comments, commit messages, or issue trackers. + +## Entry Points + +- CLI commands that write, read, inject, deploy, or review environment values. +- `.ghostable/` record changes submitted through git. +- Local identity store reads and writes. +- Automation credentials supplied through `GHOSTABLE_CI_TOKEN`. +- Provider deploy integrations that receive decrypted values. + +## Controls + +- Secret values are encrypted with XChaCha20-Poly1305 using keys derived with + HKDF-SHA256 from per-environment material. +- Device identity uses Ed25519 signatures and X25519 access-grant envelopes. +- Signed records bind devices, policy, access grants, key metadata, activity, + and value payloads to the signer. +- Production-like local-device operations require interactive OS user + confirmation unless a scoped automation credential is used. +- Local plaintext cleanup, review scanning, validation, and hygiene commands + help detect operational drift but do not replace code review. + +## Residual Risks + +- The project has not completed an external audit. +- Local plaintext can still exist during legitimate pull, run, deploy, and + debugging workflows. +- Repository metadata is intentionally public to collaborators and can leak + names, environments, device labels, change reasons, annotations, and timing. +- Security depends on prompt revocation and key rotation after device or token + compromise. + +## Review Cadence + +- Run `make security-check` before security-sensitive releases. +- Review this threat model when changing cryptographic formats, access control, + local identity storage, CI token behavior, deploy integrations, or protected + environment behavior. +- Revisit the model after an external audit and after any validated security + vulnerability. diff --git a/go.mod b/go.mod index 20763900..48a968cc 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/ghostable-dev/beta -go 1.25.0 +go 1.25.10 require ( github.com/chzyer/readline v1.5.1 diff --git a/internal/crypto/cipher_keystore_test.go b/internal/crypto/cipher_keystore_test.go new file mode 100644 index 00000000..e2ebc615 --- /dev/null +++ b/internal/crypto/cipher_keystore_test.go @@ -0,0 +1,249 @@ +package crypto + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/ghostable-dev/beta/internal/domain" +) + +func TestAESGCMRoundTripAndTamperCases(t *testing.T) { + key := bytes.Repeat([]byte{1}, keySizeBytes) + aad := []byte("project\ndefault\nAPP_KEY\nvalue") + payload, err := Encrypt(key, []byte("secret-value"), aad) + if err != nil { + t.Fatal(err) + } + if payload.Alg != AlgorithmAES256GCM { + t.Fatalf("unexpected algorithm %q", payload.Alg) + } + if nonce := mustDecodeB64(t, payload.NonceB64); len(nonce) != nonceSizeBytes { + t.Fatalf("expected nonce size %d, got %d", nonceSizeBytes, len(nonce)) + } + + plaintext, err := Decrypt(key, payload, aad) + if err != nil { + t.Fatal(err) + } + if string(plaintext) != "secret-value" { + t.Fatalf("unexpected plaintext %q", plaintext) + } + + wrongKey := bytes.Repeat([]byte{2}, keySizeBytes) + if _, err := Decrypt(wrongKey, payload, aad); err == nil { + t.Fatal("expected wrong key to fail") + } + if _, err := Decrypt(key, payload, []byte("wrong aad")); err == nil { + t.Fatal("expected wrong AAD to fail") + } + + tampered := payload + ciphertext := mustDecodeB64(t, tampered.CiphertextB64) + ciphertext[0] ^= 0xff + tampered.CiphertextB64 = base64.StdEncoding.EncodeToString(ciphertext) + if _, err := Decrypt(key, tampered, aad); err == nil { + t.Fatal("expected ciphertext tamper to fail") + } +} + +func TestAESGCMRejectsInvalidInputs(t *testing.T) { + if _, err := Encrypt([]byte("short"), []byte("secret"), nil); err == nil || !strings.Contains(err.Error(), "project key") { + t.Fatalf("expected short encryption key error, got %v", err) + } + + key := bytes.Repeat([]byte{1}, keySizeBytes) + valid := domain.EncryptedPayload{ + Alg: AlgorithmAES256GCM, + NonceB64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{1}, nonceSizeBytes)), + CiphertextB64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{2}, 32)), + } + if _, err := Decrypt(key, domain.EncryptedPayload{Alg: "wrong"}, nil); err == nil || !strings.Contains(err.Error(), "unsupported") { + t.Fatalf("expected unsupported algorithm error, got %v", err) + } + invalidNonce := valid + invalidNonce.NonceB64 = "not base64" + if _, err := Decrypt(key, invalidNonce, nil); err == nil || !strings.Contains(err.Error(), "nonce") { + t.Fatalf("expected nonce decode error, got %v", err) + } + invalidCiphertext := valid + invalidCiphertext.CiphertextB64 = "not base64" + if _, err := Decrypt(key, invalidCiphertext, nil); err == nil || !strings.Contains(err.Error(), "ciphertext") { + t.Fatalf("expected ciphertext decode error, got %v", err) + } + if _, err := Decrypt([]byte("short"), valid, nil); err == nil || !strings.Contains(err.Error(), "project key") { + t.Fatalf("expected short decryption key error, got %v", err) + } +} + +func TestNewKeyReturnsUsableKey(t *testing.T) { + key, err := NewKey() + if err != nil { + t.Fatal(err) + } + if len(key) != keySizeBytes { + t.Fatalf("expected %d-byte key, got %d", keySizeBytes, len(key)) + } + if _, err := Encrypt(key, []byte("secret"), nil); err != nil { + t.Fatal(err) + } +} + +func TestKeyStoreLoadCreateDeleteAndPermissions(t *testing.T) { + root := filepath.Join(t.TempDir(), "keys") + t.Setenv("GHOSTABLE_KEYSTORE", root) + store, err := NewKeyStore() + if err != nil { + t.Fatal(err) + } + if store.Path("project/1") != filepath.Join(root, "project_1.json") { + t.Fatalf("unexpected sanitized path: %s", store.Path("project/1")) + } + + record, key, created, err := store.LoadOrCreate("project/1", "device-1") + if err != nil { + t.Fatal(err) + } + if !created { + t.Fatal("expected first LoadOrCreate to create a key") + } + if record.Schema != domain.LocalKeySchema || record.ProjectID != "project/1" || record.DeviceID != "device-1" { + t.Fatalf("unexpected local key record: %#v", record) + } + if len(key) != keySizeBytes { + t.Fatalf("expected %d-byte key, got %d", keySizeBytes, len(key)) + } + + loadedRecord, loadedKey, err := store.Load("project/1") + if err != nil { + t.Fatal(err) + } + if loadedRecord.ProjectID != record.ProjectID || !bytes.Equal(loadedKey, key) { + t.Fatalf("loaded key mismatch: %#v", loadedRecord) + } + _, _, created, err = store.LoadOrCreate("project/1", "device-1") + if err != nil { + t.Fatal(err) + } + if created { + t.Fatal("expected existing key to be loaded") + } + if runtime.GOOS != "windows" { + assertMode(t, root, 0o700) + assertMode(t, store.Path("project/1"), 0o600) + } + + if err := store.Delete("project/1"); err != nil { + t.Fatal(err) + } + if _, _, err := store.Load("project/1"); !os.IsNotExist(err) { + t.Fatalf("expected deleted key to be missing, got %v", err) + } +} + +func TestKeyStoreRepairsLoosePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX mode checks do not apply on Windows") + } + root := filepath.Join(t.TempDir(), "keys") + if err := os.MkdirAll(root, 0o777); err != nil { + t.Fatal(err) + } + if err := os.Chmod(root, 0o777); err != nil { + t.Fatal(err) + } + store := KeyStore{root: root} + if _, _, err := store.Create("project-1", "device-1"); err != nil { + t.Fatal(err) + } + assertMode(t, root, 0o700) + assertMode(t, store.Path("project-1"), 0o600) +} + +func TestKeyStoreRejectsInvalidRecords(t *testing.T) { + root := t.TempDir() + store := KeyStore{root: root} + writeLocalKeyRecord(t, store, "project-1", domain.LocalKeyRecord{ + Schema: "wrong", + ProjectID: "project-1", + DeviceID: "device-1", + KeyB64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{1}, keySizeBytes)), + CreatedAt: time.Now().UTC(), + }) + if _, _, err := store.Load("project-1"); err == nil || !strings.Contains(err.Error(), "unsupported local key schema") { + t.Fatalf("expected schema error, got %v", err) + } + + writeLocalKeyRecord(t, store, "project-1", domain.LocalKeyRecord{ + Schema: domain.LocalKeySchema, + ProjectID: "other-project", + DeviceID: "device-1", + KeyB64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{1}, keySizeBytes)), + CreatedAt: time.Now().UTC(), + }) + if _, _, err := store.Load("project-1"); err == nil || !strings.Contains(err.Error(), "project id mismatch") { + t.Fatalf("expected project mismatch error, got %v", err) + } + + writeLocalKeyRecord(t, store, "project-1", domain.LocalKeyRecord{ + Schema: domain.LocalKeySchema, + ProjectID: "project-1", + DeviceID: "device-1", + KeyB64: "not base64", + CreatedAt: time.Now().UTC(), + }) + if _, _, err := store.Load("project-1"); err == nil { + t.Fatal("expected invalid key base64 to fail") + } + + writeLocalKeyRecord(t, store, "project-1", domain.LocalKeyRecord{ + Schema: domain.LocalKeySchema, + ProjectID: "project-1", + DeviceID: "device-1", + KeyB64: base64.StdEncoding.EncodeToString([]byte("short")), + CreatedAt: time.Now().UTC(), + }) + if _, _, err := store.Load("project-1"); err == nil || !strings.Contains(err.Error(), "local key is not") { + t.Fatalf("expected short key error, got %v", err) + } +} + +func mustDecodeB64(t *testing.T, value string) []byte { + t.Helper() + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + t.Fatal(err) + } + return decoded +} + +func writeLocalKeyRecord(t *testing.T, store KeyStore, projectID string, record domain.LocalKeyRecord) { + t.Helper() + if err := os.MkdirAll(store.root, 0o700); err != nil { + t.Fatal(err) + } + content, err := json.Marshal(record) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(store.Path(projectID), content, 0o600); err != nil { + t.Fatal(err) + } +} + +func assertMode(t *testing.T, path string, expected os.FileMode) { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != expected { + t.Fatalf("expected %s mode %o, got %o", path, expected, got) + } +} diff --git a/internal/security/security_test.go b/internal/security/security_test.go new file mode 100644 index 00000000..45fbad68 --- /dev/null +++ b/internal/security/security_test.go @@ -0,0 +1,500 @@ +package security + +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ghostable-dev/beta/internal/domain" +) + +type securityVectors struct { + Version string `json:"version"` + CanonicalJSON struct { + Input map[string]interface{} `json:"input"` + Expected string `json:"expected"` + } `json:"canonicalJSON"` + HKDF struct { + MasterB64 string `json:"masterB64"` + Scope string `json:"scope"` + EncryptionKeyB64 string `json:"encryptionKeyB64"` + HMACKeyB64 string `json:"hmacKeyB64"` + } `json:"hkdf"` + XChaCha20Poly1305 struct { + KeyB64 string `json:"keyB64"` + NonceB64 string `json:"nonceB64"` + AADB64 string `json:"aadB64"` + PlaintextB64 string `json:"plaintextB64"` + CiphertextB64 string `json:"ciphertextB64"` + } `json:"xchacha20poly1305"` + Ed25519CanonicalSignature struct { + SeedB64 string `json:"seedB64"` + PublicKeyB64 string `json:"publicKeyB64"` + Canonical string `json:"canonical"` + SignatureB64 string `json:"signatureB64"` + } `json:"ed25519CanonicalSignature"` +} + +func TestSecurityTestVectors(t *testing.T) { + vectors := loadSecurityVectors(t) + if vectors.Version != "ghostable.security-test-vectors.v1" { + t.Fatalf("unexpected vector version %q", vectors.Version) + } + + canonical, err := CanonicalJSON(vectors.CanonicalJSON.Input) + if err != nil { + t.Fatal(err) + } + if canonical != vectors.CanonicalJSON.Expected { + t.Fatalf("canonical JSON mismatch\nexpected: %s\nactual: %s", vectors.CanonicalJSON.Expected, canonical) + } + + master := mustB64(t, vectors.HKDF.MasterB64) + encKey, hmacKey, err := DeriveValueKeys(master, vectors.HKDF.Scope) + if err != nil { + t.Fatal(err) + } + if got := B64(encKey); got != vectors.HKDF.EncryptionKeyB64 { + t.Fatalf("derived encryption key mismatch: %s", got) + } + if got := B64(hmacKey); got != vectors.HKDF.HMACKeyB64 { + t.Fatalf("derived HMAC key mismatch: %s", got) + } + + plaintext, err := DecryptXChaCha( + mustB64(t, vectors.XChaCha20Poly1305.KeyB64), + domain.EncryptedPayload{ + Alg: CipherAlg, + NonceB64: vectors.XChaCha20Poly1305.NonceB64, + CiphertextB64: vectors.XChaCha20Poly1305.CiphertextB64, + }, + mustB64(t, vectors.XChaCha20Poly1305.AADB64), + ) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(plaintext, mustB64(t, vectors.XChaCha20Poly1305.PlaintextB64)) { + t.Fatalf("plaintext mismatch: %q", plaintext) + } + if _, err := DecryptXChaCha( + mustB64(t, vectors.XChaCha20Poly1305.KeyB64), + domain.EncryptedPayload{ + Alg: CipherAlg, + NonceB64: vectors.XChaCha20Poly1305.NonceB64, + CiphertextB64: vectors.XChaCha20Poly1305.CiphertextB64, + }, + []byte("wrong aad"), + ); err == nil { + t.Fatal("expected documented XChaCha vector to reject wrong AAD") + } + + signatureIdentity := domain.LocalIdentityRecord{ + Schema: domain.LocalIdentitySchema, + ProjectID: "project-fixture", + DeviceID: "dev_fixture", + SigningPublicKeyB64: vectors.Ed25519CanonicalSignature.PublicKeyB64, + SigningPrivateKeyB64: vectors.Ed25519CanonicalSignature.SeedB64, + } + signature, err := SignCanonical(map[string]string{ + "schema": "ghostable.test.v1", + "name": "fixture", + }, signatureIdentity) + if err != nil { + t.Fatal(err) + } + if signature != vectors.Ed25519CanonicalSignature.SignatureB64 { + t.Fatalf("signature mismatch: %s", signature) + } + verifyValue := map[string]interface{}{ + "schema": "ghostable.test.v1", + "name": "fixture", + "device_id": "dev_fixture", + "client_sig": "ignored", + } + if !VerifyCanonical(verifyValue, vectors.Ed25519CanonicalSignature.PublicKeyB64, signature) { + t.Fatal("expected fixture signature to verify") + } + verifyValue["name"] = "tampered" + if VerifyCanonical(verifyValue, vectors.Ed25519CanonicalSignature.PublicKeyB64, signature) { + t.Fatal("expected fixture signature to reject tampered canonical value") + } +} + +func TestDeviceIdentityBindingFailsClosed(t *testing.T) { + identity, device := mustDeviceIdentity(t, "project-1", "Laptop") + if err := VerifyDeviceRecord(device); err != nil { + t.Fatal(err) + } + if device.ID != identity.DeviceID { + t.Fatalf("device ID is not bound to identity: %s != %s", device.ID, identity.DeviceID) + } + + tampered := device + tampered.ID = "dev_tampered" + if err := VerifyDeviceRecord(tampered); err == nil || !strings.Contains(err.Error(), "bound") { + t.Fatalf("expected tampered device ID to fail binding, got %v", err) + } + + tampered = device + tampered.Status = "revoked" + if err := VerifyDeviceRecord(tampered); err == nil || !strings.Contains(err.Error(), "not active") { + t.Fatalf("expected inactive device to fail, got %v", err) + } + + tampered = device + tampered.ClientSig = "" + if err := VerifyDeviceRecord(tampered); err == nil || !strings.Contains(err.Error(), "self-signature") { + t.Fatalf("expected missing signature to fail, got %v", err) + } + + tampered = device + tampered.SigningKey.Fingerprint = Fingerprint([]byte("wrong")) + if err := VerifyDeviceRecord(tampered); err == nil || !strings.Contains(err.Error(), "signing fingerprint") { + t.Fatalf("expected signing fingerprint mismatch to fail, got %v", err) + } + + tampered = device + tampered.EncryptionKey.Fingerprint = Fingerprint([]byte("wrong")) + if err := VerifyDeviceRecord(tampered); err == nil || !strings.Contains(err.Error(), "encryption fingerprint") { + t.Fatalf("expected encryption fingerprint mismatch to fail, got %v", err) + } +} + +func TestEnvelopeRoundTripAndTamperCases(t *testing.T) { + senderIdentity, senderDevice := mustDeviceIdentity(t, "project-1", "Sender") + recipientIdentity, recipientDevice := mustDeviceIdentity(t, "project-1", "Recipient") + wrongRecipientIdentity, _ := mustDeviceIdentity(t, "project-1", "Wrong") + + meta := map[string]string{ + "project_id": "project-1", + "environment": "production", + "key_fingerprint": "fingerprint-1", + } + envelope, err := EncryptForDevice(senderIdentity, recipientDevice.EncryptionKey.PublicKey, []byte("deploy-secret"), meta) + if err != nil { + t.Fatal(err) + } + if !VerifyEnvelope(envelope, senderDevice.SigningKey.PublicKey) { + t.Fatal("expected sender envelope signature to verify") + } + if VerifyEnvelope(envelope, recipientDevice.SigningKey.PublicKey) { + t.Fatal("did not expect recipient key to verify sender signature") + } + plaintext, err := DecryptEnvelope(recipientIdentity, envelope) + if err != nil { + t.Fatal(err) + } + if string(plaintext) != "deploy-secret" { + t.Fatalf("unexpected envelope plaintext %q", plaintext) + } + if _, err := DecryptEnvelope(wrongRecipientIdentity, envelope); err == nil { + t.Fatal("expected wrong recipient identity to fail envelope decrypt") + } + + tamperedSignature := cloneEnvelope(envelope) + tamperedSignature.Meta["environment"] = "staging" + if VerifyEnvelope(tamperedSignature, senderDevice.SigningKey.PublicKey) { + t.Fatal("expected metadata tamper to invalidate envelope signature") + } + + tamperedAAD := cloneEnvelope(envelope) + tamperedAAD.AADB64 = B64([]byte(`{"project_id":"project-1","environment":"staging","key_fingerprint":"fingerprint-1"}`)) + if _, err := DecryptEnvelope(recipientIdentity, tamperedAAD); err == nil { + t.Fatal("expected AAD tamper to fail envelope decrypt") + } + + tamperedCiphertext := cloneEnvelope(envelope) + ciphertext := mustB64(t, tamperedCiphertext.CiphertextB64) + ciphertext[0] ^= 0xff + tamperedCiphertext.CiphertextB64 = B64(ciphertext) + if _, err := DecryptEnvelope(recipientIdentity, tamperedCiphertext); err == nil { + t.Fatal("expected ciphertext tamper to fail envelope decrypt") + } + + tamperedNonce := cloneEnvelope(envelope) + nonce := mustB64(t, tamperedNonce.NonceB64) + nonce[0] ^= 0xff + tamperedNonce.NonceB64 = B64(nonce) + if _, err := DecryptEnvelope(recipientIdentity, tamperedNonce); err == nil { + t.Fatal("expected nonce tamper to fail envelope decrypt") + } +} + +func TestSecretRoundTripScopeAndTamperCases(t *testing.T) { + identity, device := mustDeviceIdentity(t, "project-1", "Writer") + envKeyRecord, _, envKey, err := NewEnvironmentKey("project-1", "production", identity, device) + if err != nil { + t.Fatal(err) + } + + secret, err := BuildSecret(BuildSecretInput{ + ProjectID: "project-1", + Environment: "production", + Key: "APP_KEY", + Plaintext: "base64:secret", + ChangeReason: "test", + EnvironmentKey: envKey, + EnvironmentKeyRecord: envKeyRecord, + Identity: identity, + PreviousVersion: 2, + }) + if err != nil { + t.Fatal(err) + } + if !VerifySecretBody(secret, device.SigningKey.PublicKey, secret.ClientSig) { + t.Fatal("expected secret body signature to verify") + } + plaintext, err := DecryptSecret(secret, envKey) + if err != nil { + t.Fatal(err) + } + if plaintext != "base64:secret" { + t.Fatalf("unexpected secret plaintext %q", plaintext) + } + + tamperedAAD := secret + tamperedAAD.AAD.Env = "staging" + if VerifySecretBody(tamperedAAD, device.SigningKey.PublicKey, secret.ClientSig) { + t.Fatal("expected AAD tamper to invalidate secret signature") + } + if _, err := DecryptSecret(tamperedAAD, envKey); err == nil { + t.Fatal("expected AAD tamper to fail secret decrypt") + } + + tamperedCiphertext := secret + ciphertext := mustB64(t, strings.TrimPrefix(secret.Ciphertext, "b64:")) + ciphertext[0] ^= 0xff + tamperedCiphertext.Ciphertext = "b64:" + B64(ciphertext) + if VerifySecretBody(tamperedCiphertext, device.SigningKey.PublicKey, secret.ClientSig) { + t.Fatal("expected ciphertext tamper to invalidate secret signature") + } + if _, err := DecryptSecret(tamperedCiphertext, envKey); err == nil { + t.Fatal("expected ciphertext tamper to fail secret decrypt") + } + + tamperedHMAC := secret + tamperedHMAC.Claims = cloneStringMap(secret.Claims) + tamperedHMAC.Claims["hmac"] = "b64:" + B64(bytes.Repeat([]byte{0}, 32)) + if _, err := DecryptSecret(tamperedHMAC, envKey); err == nil { + t.Fatal("expected HMAC tamper to fail integrity check") + } + + wrongEnvKey := append([]byte{}, envKey...) + wrongEnvKey[0] ^= 0xff + if _, err := DecryptSecret(secret, wrongEnvKey); err == nil { + t.Fatal("expected wrong environment key to fail secret decrypt") + } + + prodEnc, prodHMAC, err := DeriveValueKeys(envKey, "ghostable/project-1/production") + if err != nil { + t.Fatal(err) + } + stagingEnc, stagingHMAC, err := DeriveValueKeys(envKey, "ghostable/project-1/staging") + if err != nil { + t.Fatal(err) + } + if bytes.Equal(prodEnc, stagingEnc) || bytes.Equal(prodHMAC, stagingHMAC) { + t.Fatal("expected environment scopes to derive separate keys") + } +} + +func TestEnvironmentKeyRotationProducesNewMaterial(t *testing.T) { + identity, device := mustDeviceIdentity(t, "project-1", "Owner") + previous, _, previousEnvKey, err := NewEnvironmentKey("project-1", "production", identity, device) + if err != nil { + t.Fatal(err) + } + secret, err := BuildSecret(BuildSecretInput{ + ProjectID: "project-1", + Environment: "production", + Key: "APP_KEY", + Plaintext: "old-secret", + EnvironmentKey: previousEnvKey, + EnvironmentKeyRecord: previous, + Identity: identity, + }) + if err != nil { + t.Fatal(err) + } + + rotated, rotatedEnvKey, rotatedDEK, err := RotateEnvironmentKey("project-1", "production", identity, previous) + if err != nil { + t.Fatal(err) + } + if rotated.Version != previous.Version+1 { + t.Fatalf("expected rotated version %d, got %d", previous.Version+1, rotated.Version) + } + if rotated.Fingerprint == previous.Fingerprint || bytes.Equal(rotatedEnvKey, previousEnvKey) { + t.Fatal("expected key rotation to create new environment key material") + } + decryptedRotatedKey, err := DecryptXChaCha(rotatedDEK, rotated.EncryptedKey, nil) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(decryptedRotatedKey, rotatedEnvKey) { + t.Fatal("rotated encrypted key did not unwrap to rotated environment key") + } + if _, err := DecryptSecret(secret, rotatedEnvKey); err == nil { + t.Fatal("expected old secret to reject rotated environment key") + } +} + +func TestEncodingUUIDAndCipherFailures(t *testing.T) { + random, err := RandomBytes(16) + if err != nil { + t.Fatal(err) + } + if len(random) != 16 { + t.Fatalf("expected 16 random bytes, got %d", len(random)) + } + encoded := B64(random) + decoded, err := UB64("b64:" + encoded) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(decoded, random) { + t.Fatal("base64 helper did not round trip") + } + if _, err := UB64("not base64"); err == nil { + t.Fatal("expected invalid base64 to fail") + } + if len(Fingerprint([]byte("value"))) != 64 { + t.Fatal("expected SHA-256 fingerprint hex length") + } + uuid, err := UUID() + if err != nil { + t.Fatal(err) + } + if len(uuid) != 36 || uuid[14] != '4' { + t.Fatalf("unexpected UUID: %s", uuid) + } + if _, err := DecryptXChaCha([]byte("short"), domain.EncryptedPayload{Alg: CipherAlg}, nil); err == nil { + t.Fatal("expected short key to fail") + } + if _, err := DecryptXChaCha(bytes.Repeat([]byte{1}, 32), domain.EncryptedPayload{Alg: "wrong"}, nil); err == nil { + t.Fatal("expected unsupported cipher to fail") + } +} + +func TestIdentityStoreFileSaveLoadDeleteAndValidation(t *testing.T) { + root := t.TempDir() + store := IdentityStore{fileRoot: root} + identity := domain.LocalIdentityRecord{ + Schema: domain.LocalIdentitySchema, + ProjectID: "project-1", + DeviceID: "device-1", + } + if err := store.Save(identity); err != nil { + t.Fatal(err) + } + loaded, err := store.Load("project-1") + if err != nil { + t.Fatal(err) + } + if loaded.ProjectID != "project-1" || loaded.DeviceID != "device-1" { + t.Fatalf("unexpected loaded identity: %#v", loaded) + } + if store.Path("project/1") != filepath.Join(root, "project_1.json") { + t.Fatalf("unexpected sanitized path: %s", store.Path("project/1")) + } + if err := store.Delete("project-1"); err != nil { + t.Fatal(err) + } + if _, err := store.Load("project-1"); !os.IsNotExist(err) { + t.Fatalf("expected deleted identity to be missing, got %v", err) + } + + invalid := identity + invalid.Schema = "wrong" + writeIdentityFile(t, store, invalid) + if _, err := store.Load("project-1"); err == nil || !strings.Contains(err.Error(), "unsupported identity schema") { + t.Fatalf("expected invalid schema error, got %v", err) + } + + invalid = identity + invalid.ProjectID = "other-project" + writeIdentityFile(t, store, invalid) + if _, err := store.Load("project-1"); err == nil || !strings.Contains(err.Error(), "project id mismatch") { + t.Fatalf("expected project mismatch error, got %v", err) + } +} + +func loadSecurityVectors(t *testing.T) securityVectors { + t.Helper() + path := filepath.Join("..", "..", "docs", "security", "test-vectors.json") + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + + var vectors securityVectors + decoder := json.NewDecoder(file) + decoder.UseNumber() + if err := decoder.Decode(&vectors); err != nil { + t.Fatal(err) + } + return vectors +} + +func mustB64(t *testing.T, value string) []byte { + t.Helper() + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + t.Fatal(err) + } + return decoded +} + +func mustDeviceIdentity(t *testing.T, projectID string, name string) (domain.LocalIdentityRecord, domain.DeviceRecord) { + t.Helper() + identity, device, err := NewDeviceIdentity(projectID, name, "test") + if err != nil { + t.Fatal(err) + } + return identity, device +} + +func cloneEnvelope(envelope domain.EnvelopeJSON) domain.EnvelopeJSON { + cloned := envelope + if envelope.Meta != nil { + cloned.Meta = cloneStringMap(envelope.Meta) + } + return cloned +} + +func cloneStringMap(input map[string]string) map[string]string { + cloned := make(map[string]string, len(input)) + for key, value := range input { + cloned[key] = value + } + return cloned +} + +func writeIdentityFile(t *testing.T, store IdentityStore, identity domain.LocalIdentityRecord) { + t.Helper() + content, err := json.Marshal(identity) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(store.fileRoot, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(store.filePath("project-1"), content, 0o600); err != nil { + t.Fatal(err) + } +} + +func TestFixtureSignatureIsStandardEd25519(t *testing.T) { + vectors := loadSecurityVectors(t) + publicKey := ed25519.PublicKey(mustB64(t, vectors.Ed25519CanonicalSignature.PublicKeyB64)) + signature := mustB64(t, vectors.Ed25519CanonicalSignature.SignatureB64) + if !ed25519.Verify(publicKey, []byte(vectors.Ed25519CanonicalSignature.Canonical), signature) { + t.Fatal("fixture signature did not verify with standard Ed25519") + } +} diff --git a/internal/userpresence/userpresence_test.go b/internal/userpresence/userpresence_test.go index 21507607..b77d488d 100644 --- a/internal/userpresence/userpresence_test.go +++ b/internal/userpresence/userpresence_test.go @@ -27,3 +27,33 @@ func TestVerifyNonInteractiveProtectedAccessFailsBeforePlatformPrompt(t *testing } } } + +func TestConfirmationMessageAndOperationLabels(t *testing.T) { + tests := []struct { + request Request + want string + }{ + { + request: Request{Environment: "production", Operation: "env.pull"}, + want: "access Ghostable production secrets for env pull", + }, + { + request: Request{Environment: " live ", Operation: "env.run"}, + want: "access Ghostable live secrets for env run", + }, + { + request: Request{Environment: "", Operation: "var.pull"}, + want: "access Ghostable protected environment secrets for var pull", + }, + { + request: Request{Environment: "production", Operation: "deploy"}, + want: "access Ghostable production secrets for deploy", + }, + } + + for _, tt := range tests { + if got := confirmationMessage(tt.request); got != tt.want { + t.Fatalf("expected %q, got %q", tt.want, got) + } + } +} diff --git a/internal/userpresence/userpresence_unix_test.go b/internal/userpresence/userpresence_unix_test.go index 4fb79eb0..1e8e5821 100644 --- a/internal/userpresence/userpresence_unix_test.go +++ b/internal/userpresence/userpresence_unix_test.go @@ -5,9 +5,23 @@ package userpresence import "testing" func TestSudoPromptUsesConfirmationMessage(t *testing.T) { - prompt := sudoPrompt(Request{Environment: "production", Operation: "deploy"}) - expected := "access Ghostable production secrets for deploy Password: " - if prompt != expected { - t.Fatalf("expected sudo prompt %q, got %q", expected, prompt) + tests := []struct { + request Request + want string + }{ + { + request: Request{Environment: "production", Operation: "deploy"}, + want: "access Ghostable production secrets for deploy Password: ", + }, + { + request: Request{Environment: "production", Operation: "env.run"}, + want: "access Ghostable production secrets for env run Password: ", + }, + } + + for _, tt := range tests { + if got := sudoPrompt(tt.request); got != tt.want { + t.Fatalf("expected sudo prompt %q, got %q", tt.want, got) + } } }