diff --git a/docs/onboarding/1-template-engine/2-functions.md b/docs/onboarding/1-template-engine/2-functions.md index 2daffd2b..f004651c 100644 --- a/docs/onboarding/1-template-engine/2-functions.md +++ b/docs/onboarding/1-template-engine/2-functions.md @@ -53,14 +53,14 @@ specialized functions. Apply BASE64 URL encoding to given input. -```ruby +```gotemplate {{ paranoidPassword | b64urlenc }} fjYySGJoa00iQkdTaXRUQ2d-RVgwfHMwI2tvcG5Yc0xne3RfQV9HZU5YQ3ZTT243XWUyeDVqNjVNQnRMJEdzNA== ``` Decode a BASE64 URL encoded string. -```ruby +```gotemplate {{ "fjYySGJoa00iQkdTaXRUQ2d-RVgwfHMwI2tvcG5Yc0xne3RfQV9HZU5YQ3ZTT243XWUyeDVqNjVNQnRMJEdzNA==" | b64urldec }} ~62HbhkM"BGSitTCg~EX0|s0#kopnXsLg{t_A_GeNXCvSOn7]e2x5j65MBtL$Gs4 ``` @@ -74,13 +74,13 @@ encode crypto-material and keep ownership visible to humans. > This is the encoding used by container sealing identities. -```ruby +```gotemplate {{ bech32enc <[]BYTE> }} ``` For example with an Ed25519 Public key: -```ruby +```gotemplate {{ $key := cryptoPair "ed25519" }} {{ bech32enc "security" $key.Public }} security19f29qq5vq73tdrhspdzkqcdf2exewg2g6xcxe5h74y72qsv7c00sx57ny0 @@ -90,7 +90,7 @@ security19f29qq5vq73tdrhspdzkqcdf2exewg2g6xcxe5h74y72qsv7c00sx57ny0 Apply Shell escaping strategy to allow a string to be safely used in a shell script. -```ruby +```gotemplate {{ paranoidPassword | shellescape }} 'tGO48jRkfOiXv8=p?eV^wi7tqJz`ABeQy1ZXk2WE(E1XWuS6%$j+X>QVx93W*WEY' ``` @@ -99,7 +99,7 @@ Apply Shell escaping strategy to allow a string to be safely used in a shell scr Apply url character escaping strategy for components used in path -```ruby +```gotemplate https://ingester.es.cloud/{{ tenant | urlPathEscape }}/api/v1 ``` @@ -107,7 +107,7 @@ https://ingester.es.cloud/{{ tenant | urlPathEscape }}/api/v1 Apply url character escaping strategy for components used in query -```ruby +```gotemplate https://logstash:{{ paranoidPassword | urlQueryEscape }}@ingester.es.cloud:1234 https://logstash:K3iDayow9%5Cav67HawD6%210k~8lhcm8oLVUBt2wE%3E%5DLBJQJVj%3AfIx%2Fuo%40%7B%3D6kvgXHK@ingester.es.cloud:1234% ``` @@ -116,7 +116,7 @@ https://logstash:K3iDayow9%5Cav67HawD6%210k~8lhcm8oLVUBt2wE%3E%5DLBJQJVj%3AfIx%2 Apply JSON ecaping strategy to a string -```ruby +```gotemplate {{ "backslash: \, A: & <" | jsonEscape }} backslash: \\, A: \u0026 \u003c ``` @@ -125,7 +125,7 @@ backslash: \\, A: \u0026 \u003c #### secret -```ruby +```gotemplate {{ with secret "secrets/application" }} {{ .foo }} {{ end }} @@ -180,7 +180,7 @@ NVQ3VjFsTlFKIzAtd25MMWtqYURWT1dJZzBkdERVLVdEOmxMY3NvJHRsWnZ8JVhRcDNZMU92OTJQSmB3 #### customPassword -```ruby +```gotemplate {{ customPassword }} # 128 chars with 16 digits, 16 symbols with repetition {{ customPassword 128 16 16 false true }} @@ -194,7 +194,7 @@ o)BDz#J|PDyI!+tBKmNSE1lMqh9gfSvVG%juxf9XonBl*N:sb#tgevct9.cDcdAhpt22/MpcbEtM@yM2 #### paranoidPassword -```ruby +```gotemplate {{ paranoidPassword }} # 64 chars with 10 digits, 10 symbols with upper and lower case and repetition allowed {{ customPassword 64 10 10 false true }} @@ -208,7 +208,7 @@ n4[(1[CL6HlNuK95F[qSJd5kUiK.AwV7t)WjKKttgVgn=p9(=0UbrT7vgAhy.VzZ #### noSymbolPassword -```ruby +```gotemplate {{ noSymbolPassword }} # Same as : 32 chars with 10 digits, no symbol with upper and lower case and repetition allowed {{ customPassword 32 10 0 false true }} @@ -222,7 +222,7 @@ V4xQxl7h6QWUr3do70ER5m377cmQaSGX #### strongPassword -```ruby +```gotemplate {{ strongPassword }} # Same as : 32 chars with 10 digits, 10 symbols with upper and lower case and repetition allowed {{ customPassword 32 10 10 false true }} @@ -238,7 +238,7 @@ Output : #### customDiceware -```ruby +```gotemplate {{ customDiceware }} # Generate diceware passphrase {{ customDiceware 6 }} @@ -252,7 +252,7 @@ brunch-starch-germinate-retool-huntsman-entourage #### basicDiceware -```ruby +```gotemplate {{ basicDiceware }} # Same as {{ customDiceware 4 }} @@ -266,7 +266,7 @@ grill-zit-grading-hamlet #### strongDiceware -```ruby +```gotemplate {{ strongDiceware }} # Same as {{ customDiceware 8 }} @@ -280,7 +280,7 @@ camper-unfilled-moonbeam-veal-vitality-snowdrop-doorman-tinsmith #### paranoidDiceware -```ruby +```gotemplate {{ paranoidDiceware }} # Same as {{ customDiceware 12 }} @@ -298,7 +298,7 @@ sweat-dismantle-county-unlucky-shrank-reaffirm-drainable-mustiness-appendix-scra Generate a symmetic encryption/decryption key. -```ruby +```gotemplate {{ cryptoKey }} # For AES key {{ cryptoKey "aes:256" }} @@ -326,7 +326,7 @@ Generate asymmetic key pairs. > profile is planned for dynamically generate keypair according to targeted > requirements (fips140). -```ruby +```gotemplate {{ cryptoPair }} # For RSA recommended (actually RSA2048) {{ $key := cryptoPair "rsa" }} @@ -348,9 +348,16 @@ Where `type` could be : #### toJwk -Encode the given cryptoKey as JWK. +Encode the given cryptoKey as JWK with automatic algorithm detection. -```ruby +> **Note**: The `alg` (algorithm) field is automatically included based on key type: +> - RSA keys → `RS256` +> - ECDSA P-256 → `ES256`, P-384 → `ES384`, P-521 → `ES512` +> - Ed25519 → `EdDSA` +> +> This makes template-generated keys directly compatible with `harp transform sign` without additional processing. + +```gotemplate {{ $key := cryptoPair "ec:p384" }} # Get the private key and encode it as JWK {{ $key.Private | toJwk }} @@ -360,12 +367,13 @@ Encode the given cryptoKey as JWK. Output : -```ruby +```gotemplate # Get the private key and encode it as JWK {{ $key.Private | toJwk }} { "kty":"EC", "kid":"8rvz08-Aq05Vq-a40dpJFt5VwvAgdfJPGt9TKkchNUM=", + "alg":"ES384", "crv":"P-384", "x":"KfTYa3f9WKgg5npBsBfw6ivTJgQS0xP2KbvQHU4WtEzllvjOsz1D2WZCPq9X-aUq","y":"88SZwdKWNb3GONuO0C8LqI3aCtTBf2SCOiKgLNLinWSH_Dval0_euuCv8WRTVYcL","d":"jIcdBVkUfXs1U5SbtcmH2aqL6vXJTMmBtK9SFaoi9HDmSb7VeQSvMQZmUzDTgn9N" } @@ -374,16 +382,163 @@ Output : { "kty":"EC", "kid":"8rvz08-Aq05Vq-a40dpJFt5VwvAgdfJPGt9TKkchNUM=", + "alg":"ES384", "crv":"P-384", "x":"KfTYa3f9WKgg5npBsBfw6ivTJgQS0xP2KbvQHU4WtEzllvjOsz1D2WZCPq9X-aUq","y":"88SZwdKWNb3GONuO0C8LqI3aCtTBf2SCOiKgLNLinWSH_Dval0_euuCv8WRTVYcL" } ``` +##### Template Keys for Vault Storage + +Template-generated keys can be stored directly in Hashicorp Vault using Harp bundle templates. + +**Method 1: Using Bundle Template (Recommended)** + +Create a bundle template `signing-keys.yaml`: + +```yaml +# yaml-language-server: $schema=api/jsonschema/harp.bundle.v1/Template.json +apiVersion: harp.elastic.co/v1 +kind: BundleTemplate +meta: + name: "signing-keys" + owner: "security-team@example.com" + description: "Signing keys for example services" +spec: + selector: + platform: "examplePlatform" + product: "platform" + quality: "dev" + version: "v1.0.0" + namespaces: + application: + - name: "security/signing/api" + description: "test signature" + secrets: + - suffix: "credentials" + template: | + {{- $key := cryptoPair "ed25519" -}} + { + "private_key": {{ $key.Private | toJwk | toJson }}, + "public_key": {{ $key.Public | toJwk | toJson }}, + "algorithm": "EdDSA", + "created_at": "{{ now | date "2006-01-02T15:04:05Z07:00" }}" + } + - name: "security/signing/worker" + description: "test signing" + secrets: + - suffix: "credentials" + description: "example signing secret for workers" + template: | + {{- $key := cryptoPair "rsa" -}} + { + "private_key": {{ $key.Private | toJwk | toJson }}, + "public_key": {{ $key.Public | toJwk | toJson }}, + "algorithm": "RS256", + "created_at": "{{ now | date "2006-01-02T15:04:05Z07:00" }}" + } +``` + +**Generate and push to Vault:** + +```bash +# Render the bundle template and push directly to Vault +harp from template --in signing-keys.yaml | harp to vault + +# Or save bundle first (recommended for audit trail) +harp from template --in signing-keys.yaml --out signing-keys.bundle +harp to vault --in signing-keys.bundle + +# View what was generated (optional) +harp bundle dump --in signing-keys.bundle --data-only | jq . +``` + +**Retrieve and use for signing:** + +```bash +# Extract private key using piped workflow +PRIVATE_KEY=$(harp from vault --path "app/dev/examplePlatform/platform/v1.0.0/security/signing/api/credentials" \ +| harp bundle read --path "app/dev/examplePlatform/platform/v1.0.0/security/signing/api/credentials" --field private_key \ +| jq .) + +# Encode for signing using Harp-native encoding +PRIVATE_B64=$(echo "$PRIVATE_KEY" | harp transform encode --encoding base64url --in -) + +# Sign a message +echo -n "message" | harp transform sign --key "jws:$PRIVATE_B64" +``` + +**Method 2: Direct JSON Template** + +For simple one-off key generation: + +```bash +# Create inline template +cat > keygen.tmpl <<'EOF' +{{- $key := cryptoPair "ed25519" -}} +{{- $jwk := $key.Private | toJwk | fromJson -}} +{ + "app/production/security/signing/default/credentials": { + "private_key": {{ $key.Private | toJwk | toJson }}, + "public_key": {{ $key.Public | toJwk | toJson }}, + "algorithm": {{ $jwk.alg | toJson }} + } +} +EOF + +# Generate keys as JSON +harp template --in keygen.tmpl | harp from jsonmap | harp bundle dump --data-only | jq . + +# Push to vault +harp template --in keygen.tmpl | harp from jsonmap | harp to vault +``` + +##### Key Generation Best Practices + +**Key Generation** + +The following methods exist to generate JWK keys. + +| Method | Command | Output Includes `alg` | +|--------|---------|----------------------| +| CLI | `harp keygen jwk --algorithm EdDSA` | ✅ Yes | +| Template | `{{ cryptoPair "ed25519" \| toJwk }}` | ✅ Yes | + +**When to use each:** + +- **CLI (`harp keygen jwk`)**: + - One-time key generation + - Interactive workflows + - Specifying exact algorithm (ES256, ES384, RS256, etc.) + +- **Template (`cryptoPair`)**: + - Batch key generation + - Integrated secret bundles + - Vault seeding workflows + - Reproducible infrastructure-as-code + +**Algorithm Selection Guide** + +```gotemplate +# Ed25519 (Recommended for most use cases) +{{ $key := cryptoPair "ed25519" }} +# Produces: alg="EdDSA", fastest, smallest keys + +# RSA (For legacy compatibility) +{{ $key := cryptoPair "rsa" }} # 2048-bit, alg="RS256" +{{ $key := cryptoPair "rsa:4096" }} # 4096-bit, alg="RS256" + +# ECDSA (For NIST compliance) +{{ $key := cryptoPair "ec:p256" }} # P-256, alg="ES256" +{{ $key := cryptoPair "ec:p384" }} # P-384, alg="ES384" +{{ $key := cryptoPair "ec:p521" }} # P-521, alg="ES512" +``` + #### fromJwk Decode a JWK encoded key. -```ruby +```gotemplate {{ $key := fromJwk .Values.jwk }} # Convert JWK encoded key to native one {{ $key.Private | toJwk }} @@ -395,7 +550,7 @@ Decode a JWK encoded key. Encode the given cryptoKey as PEM. -```ruby +```gotemplate {{ $key := cryptoPair "rsa" }} # Get the private key and encode it as PEM {{ $key.Private | toPem }} @@ -405,7 +560,7 @@ Encode the given cryptoKey as PEM. Output : -```ruby +```gotemplate # Get the private key and encode it as PEM # {{ $key.Private | toPem }} "-----BEGIN RSA PRIVATE KEY----- @@ -453,7 +608,7 @@ FwIDAQAB Encrypt the given PEM with a passphrase. -```ruby +```gotemplate {{ $key := cryptoPair "rsa" }} # Generate a passphrase {{ $passphrase := paranoidDiceware }} @@ -465,7 +620,7 @@ Encrypt the given PEM with a passphrase. Output : -```ruby +```gotemplate # {{ $key := cryptoPair "rsa" }}{{ $passphrase := paranoidDiceware }}{{ $privPem := $key.Private | toPem }}{{ $passphrase }}\n{{ encryptPem $privPem $passphrase }} "helmet-flashcard-context-tidiness-osmosis-sled-shimmer-jeeringly-exhale-aloof-defuse-pranker -----BEGIN ENCRYPTED PRIVATE KEY----- @@ -504,7 +659,7 @@ JFS6EYe2rXuOqxSTzurUTPC4U3bBwtCwTpG/YVAzIkiL7BDfhxB0X5aG Encrypt input using JWE. -```ruby +```gotemplate {{ $key := cryptoPair "rsa" }} # Get the private key and encode it as PEM {{ $pk := toPem $key.Private }} @@ -516,7 +671,7 @@ Encrypt input using JWE. Decrypt input encoded as JWE. -```ruby +```gotemplate {{ $key := cryptoPair "rsa" }} # Get the private key and encode it as PEM {{ $pk := toPem $key.Private }} @@ -530,7 +685,7 @@ Decrypt input encoded as JWE. Extract claims _WITHOUT_ signature validation. -```ruby +```gotemplate {{ $token = "..." }} # Parse the JWT {{ $t := parseJwt $token }} @@ -544,7 +699,7 @@ Extract claims _WITHOUT_ signature validation. Extract claims _WITH_ signature validation. -```ruby +```gotemplate {{ $token = "..." }} {{ $key = "..." }} # Parse the JWT @@ -559,7 +714,7 @@ Extract claims _WITH_ signature validation. Encode the given key for OpenSSH usages. -```ruby +```gotemplate {{ $key := cryptoPair "ssh" }} # Get the private key and encode it as OpenSSH private key {{ $key.Private | toSSH }} @@ -569,7 +724,7 @@ Encode the given key for OpenSSH usages. Output : -```ruby +```gotemplate # {{ $key := cryptoPair "ssh" }}{{ $key.Private | toSSH }}\n{{ $key.Public | toPem }}\n{{ $key.Public | toSSH }} "-----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz @@ -590,7 +745,7 @@ ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDNglXzKENVQqbJymMYEK47ys729tmV4GES9O4uYBXGQ Create a JWT. -```ruby +```gotemplate {{ $key := cryptoPair "ec" }} {{ $claims := fromJson "{\"sub\":\"test\"}" }} {{ toJws $claims $key.Private }} @@ -600,7 +755,7 @@ Create a JWT. Read a PEM encoded string and decode as `*x509.Certificate` - https://pkg.go.dev/crypto/x509#Certificate. -```ruby +```gotemplate {{ $cert := parsePemCertificate .Values.cert }} {{ $cert.Issuer.ToRDNSequence }} {{ $cert.NotBefore }} @@ -610,7 +765,7 @@ Read a PEM encoded string and decode as `*x509.Certificate` - https://pkg.go.dev Read all PEM encoded string and decode as a collection of `*x509.Certificate` - https://pkg.go.dev/crypto/x509#Certificate. -```ruby +```gotemplate {{ $certs := parsePemCertificateBundle .Values.certs }} {{ range $i, $cert := $certs }} {{ $cert.Issuer.ToRDNSequence }} @@ -622,7 +777,7 @@ Read all PEM encoded string and decode as a collection of `*x509.Certificate` - Read a PEM encoded string and decode as a collection of `*x509.CertificateRequest` - https://pkg.go.dev/crypto/x509#CertificateRequest. -```ruby +```gotemplate {{ $csr := parsePemCertificateRequest .Values.csr }} {{ $csr.PublicKey | toJwk }} ``` @@ -636,7 +791,7 @@ Read a PEM encoded string and decode as a collection of `*x509.CertificateReques Encode the given `*x509.Certificate` for [DANE-TLSA](https://datatracker.ietf.org/doc/html/rfc6698) validation. -```ruby +```gotemplate {{ $cert := parsePemCertificate .Values.cert }} _dane.example.com. IN TLSA 2 1 1 {{ toTLSA 1 1 $cert | upper }} ``` diff --git a/docs/onboarding/4-value-transformers/3-signature.md b/docs/onboarding/4-value-transformers/3-signature.md index d486ddab..0623dd3b 100644 --- a/docs/onboarding/4-value-transformers/3-signature.md +++ b/docs/onboarding/4-value-transformers/3-signature.md @@ -14,3 +14,88 @@ limitations under the License. --> +# Signature Transformers + +## Overview + +| Transformer | Format | Algorithms | Use Case | +|------------|--------|-----------|----------| +| `jws:` | Compact JWT | EdDSA, ES256/384/512, RS256/384/512, PS256/384/512 | API tokens, standard JWTs | +| `paseto:` | PASETO v4 | Ed25519 | Modern alternative to JWT | +| `raw:` | Binary | Ed25519, ECDSA, RSA | Detached signatures, custom formats | + +## Key Generation + +### CLI Method + +```bash +# Generate Ed25519 key (recommended) +harp keygen jwk --algorithm EdDSA + +# Generate with other algorithms +harp keygen jwk --algorithm ES256 # ECDSA P-256 +harp keygen jwk --algorithm RS256 # RSA 2048 +harp keygen jwk --algorithm PS256 # RSA-PSS +``` + +### Template Method + +``` +{{- $key := cryptoPair "ed25519" -}} +{{ $key.Private | toJwk }} # Includes alg field automatically +``` + +**Both methods produce JWK with `alg` field** - ready for signing. + +## Signing Operations + +### Basic JWS Signing + +Keys must be **base64url-encoded JWK** with `jws:` prefix: + +```bash +# Sign a message +JWK="$(harp keygen jwk --algorithm EdDSA)" +KEY_B64=$(echo "$JWK" | harp transform encode --encoding base64url --in -) +echo -n "message" | harp transform sign --key "jws:$KEY_B64" --deterministic + +# Output: eyJhbGciOiJFZERTQSJ9.dGVzdCBtZXNzYWdl.signature... +``` + +### Verification + +```bash +# Verify signature +echo "eyJhbGciOiJFZERTQSJ9.dGVzdCBtZXNzYWdl.signature..." | \ + harp transform verify --key "jws:$PUBLIC_KEY_B64" + +# Output: test message +``` + +## Algorithm Details + +### Supported Algorithms Matrix + +| Algorithm | Key Type | Signature Size | Speed | Security | FIPS Compatible | +|-----------|----------|----------------|-------|----------|----------------| +| **EdDSA** | Ed25519 | 64 bytes | ⚡⚡⚡ | High | No | +| **ES256** | ECDSA P-256 | ~72 bytes | ⚡⚡ | Medium-High | Yes | +| **ES384** | ECDSA P-384 | ~104 bytes | ⚡⚡ | High | Yes | +| **ES512** | ECDSA P-521 | ~139 bytes | ⚡⚡ | Very High | Yes | +| **RS256** | RSA-2048 | 256 bytes | ⚡ | Medium | Yes | +| **RS384** | RSA-2048 | 256 bytes | ⚡ | Medium-High | Yes | +| **RS512** | RSA-2048 | 256 bytes | ⚡ | High | Yes | +| **PS256** | RSA-PSS | 256 bytes | ⚡ | Medium-High | Yes | +| **PS384** | RSA-PSS | 256 bytes | ⚡ | High | Yes | +| **PS512** | RSA-PSS | 256 bytes | ⚡ | Very High | Yes | + +## Complete Examples + +See [Signing Workflows](4-signing-workflows.md) for complete end-to-end examples including: +- Vault storage integration +- Deterministic vs non-deterministic signing +- All algorithm variants + +* [Previous topic](2-encryption.md) +* [Index](../) +* [Next topic](4-signing-workflows.md) diff --git a/docs/onboarding/4-value-transformers/4-signing-workflows.md b/docs/onboarding/4-value-transformers/4-signing-workflows.md new file mode 100644 index 00000000..3e11f02f --- /dev/null +++ b/docs/onboarding/4-value-transformers/4-signing-workflows.md @@ -0,0 +1,198 @@ + + +# Signing Workflows + +## Workflow 1: Bundle Template → Vault Storage → CLI Signing + +### Step 1: Create Bundle Template + +Create a bundle template file `service-keys.yaml`: + +```yaml +apiVersion: harp.elastic.co/v1 +kind: BundleTemplate +meta: + name: "signing-keys" + owner: test@elastic.co + description: "signing-keys example" +spec: + selector: + platform: "examplePlatform" + quality: "dev" + product: "service-signing-keys" + version: "v1.0.0" + namespaces: + application: + - name: "signing-keys" + description: "signing keys for services" + secrets: + - suffix: "crypto/signing-key" + description: "example signing key" + template: | + {{- $key := cryptoPair "ed25519" -}} + {{- $jwk := $key.Private | toJwk | fromJson -}} + { + "private_key": {{ $key.Private | toJwk | toJson }}, + "public_key": {{ $key.Public | toJwk | toJson }}, + "algorithm": {{ $jwk.alg | toJson }}, + "service": "service-signing-keys", + "rotation_date": "{{ now | date "2006-01-02" }}", + "expires_at": "{{ now | dateModify "8760h" | date "2006-01-02" }}" + } +``` + +### Step 2: Generate and Store in Vault + +```bash +# Render bundle template and push directly to Vault +harp from template --in service-keys.yaml | \ + harp to vault + +# Or save bundle first (recommended for audit trail) +harp from template --in service-keys.yaml --out service-keys.bundle +harp to vault --in service-keys.bundle + +# View what was generated (optional) +harp bundle dump --in service-keys.bundle --data-only | jq . +``` + +### Step 3: Retrieve and Sign + +```bash +# Pull keys from Vault for specific service +PRIVATE_KEY=$(harp from vault --path "app/dev/examplePlatform/service-signing-keys/v1.0.0/signing-keys/crypto/signing-key" \ +| harp bundle read --path "app/dev/examplePlatform/service-signing-keys/v1.0.0/signing-keys/crypto/signing-key" --field private_key \ +| jq .) + +# Encode for signing +PRIVATE_B64=$(echo "$PRIVATE_KEY" | harp transform encode --encoding base64url --in -) + +# Sign a message +echo -n "API request payload" | harp transform sign \ + --key "jws:$PRIVATE_B64" \ + --deterministic + +# Output: eyJhbGciOiJFZERTQSIsImtp... +``` + +### Step 4: Verify Signature + +```bash +# Extract public key using piped workflow +PUBLIC_KEY=$(harp from vault --path "app/dev/examplePlatform/service-signing-keys/v1.0.0/signing-keys/crypto/signing-key" \ +| harp bundle read --path "app/dev/examplePlatform/service-signing-keys/v1.0.0/signing-keys/crypto/signing-key" --field public_key \ +| jq .) + +# Encode for verification +PUBLIC_B64=$(echo "$PUBLIC_KEY" | harp transform encode --encoding base64url --in -) + +# Verify signature +# echo "eyJhbGciOiJFZERTQSIsImtp.. | harp transform verify --key "jws:$PUBLIC_B64" +echo "$OUTPUT_FROM_STEP_3" | harp transform verify \ + --key "jws:$PUBLIC_B64" + +# Output: API request payload +``` + +### Step 5: Key Rotation (Bonus) + +```bash +# Generate new keys with updated template +harp from template --in service-keys.yaml --out service-keys-new.bundle + +# Compare old and new keys +harp bundle diff \ + --old service-keys.bundle \ + --new service-keys-new.bundle + +# Update Vault with new keys +harp to vault --in service-keys-new.bundle + +# Archive old bundle for rollback capability +cp service-keys.bundle service-keys-backup-$(date +%Y%m%d).bundle +``` + +--- + +## Workflow 2: All Algorithms in One Template + +```ruby +{{- $keys := dict -}} +{{- $algMap := dict "ed25519" "EdDSA" "rsa" "RS256" "ec:p256" "ES256" "ec:p384" "ES384" -}} +{{- range $type, $alg := $algMap -}} + {{- $key := cryptoPair $type -}} + {{- $jwk := $key.Private | toJwk | fromJson -}} + {{- $_ := set $keys $alg (dict + "kty" $jwk.kty + "kid" $jwk.kid + "alg" $jwk.alg + "private_b64" ($key.Private | toJwk | b64enc) + "public_b64" ($key.Public | toJwk | b64enc) + ) -}} +{{- end -}} +{{ $keys | toJson }} +``` + +**Output structure:** + +```json +{ + "EdDSA": { + "kty": "OKP", + "kid": "abc123...", + "alg": "EdDSA", + "private_b64": "eyJrdHkiOiJPS1Ai...", + "public_b64": "eyJrdHkiOiJPS1Ai..." + }, + "RS256": { ... }, + "ES256": { ... }, + "ES384": { ... } +} +``` + +--- + +## Workflow 3: Deterministic vs Non-Deterministic Signing + +### Deterministic (Reproducible) + +```bash +# Same input + key = same signature (RFC 6979) +echo -n "test" | harp transform sign --key "jws:$KEY" --deterministic +# Always produces: eyJhbGciOiJFZERTQSJ9.dGVzdA.gCushVU... + +# Use cases: +# - Testing and validation +# - Signature caching +# - Reproducible builds +``` + +### Non-Deterministic (Random Nonce) + +```bash +# Each signature is unique due to random nonce +echo -n "test" | harp transform sign --key "jws:$KEY" +# Produces: eyJhbGciOiJFZERTQSIsIm5vbmNlIjoiUnlmQiJ9.dGV... + +# Use cases: +# - Protection against replay attacks +# - Time-sensitive operations +# - Production security +``` + +* [Previous topic](3-signature.md) +* [Index](../) diff --git a/docs/onboarding/README.md b/docs/onboarding/README.md index d7c1007a..022343f6 100644 --- a/docs/onboarding/README.md +++ b/docs/onboarding/README.md @@ -75,6 +75,13 @@ vault secrets enable -version=2 -path=legacy kv 3. [BundleTemplate](3-secret-bundle/3-template.md) 4. [BundlePatch](3-secret-bundle/4-patch.md) +### Value Transformers + +1. [Introduction](4-value-transformers/1-introduction.md) +2. [Encryption](4-value-transformers/2-encryption.md) +3. [Signature](4-value-transformers/3-signature.md) +4. [Signing Workflows](4-value-transformers/4-signing-workflows.md) + ### Secret Workflow > Section in development diff --git a/pkg/sdk/security/crypto/encoder.go b/pkg/sdk/security/crypto/encoder.go index eaa45139..0c0f5977 100644 --- a/pkg/sdk/security/crypto/encoder.go +++ b/pkg/sdk/security/crypto/encoder.go @@ -47,6 +47,36 @@ import ( "golang.org/x/crypto/ssh" ) +// algorithmForKey determines the appropriate JWS algorithm for a given key. +func algorithmForKey(key interface{}) string { + switch k := key.(type) { + case *rsa.PrivateKey, *rsa.PublicKey: + return string(jose.RS256) + case *ecdsa.PrivateKey: + return algorithmForECDSACurve(k.Curve) + case *ecdsa.PublicKey: + return algorithmForECDSACurve(k.Curve) + case ed25519.PrivateKey, ed25519.PublicKey: + return string(jose.EdDSA) + default: + return "" + } +} + +// algorithmForECDSACurve returns the JWS algorithm for an ECDSA curve. +func algorithmForECDSACurve(curve elliptic.Curve) string { + switch curve { + case elliptic.P256(): + return string(jose.ES256) + case elliptic.P384(): + return string(jose.ES384) + case elliptic.P521(): + return string(jose.ES512) + default: + return "" + } +} + // ToJWK encodes given key using JWK. func ToJWK(key interface{}) (string, error) { // Check key @@ -54,9 +84,6 @@ func ToJWK(key interface{}) (string, error) { return "", fmt.Errorf("unable to encode nil key") } - // Wrap key - keyWrapper := jose.JSONWebKey{Key: key, KeyID: ""} - // Don't process Ed25519 keys if fips.Enabled() { switch key.(type) { @@ -65,6 +92,13 @@ func ToJWK(key interface{}) (string, error) { } } + // Wrap key with algorithm + keyWrapper := jose.JSONWebKey{ + Key: key, + Algorithm: algorithmForKey(key), + KeyID: "", + } + // Generate thumbprint thumb, err := keyWrapper.Thumbprint(crypto.SHA512_256) if err != nil { @@ -74,7 +108,7 @@ func ToJWK(key interface{}) (string, error) { // Assign thumbprint keyWrapper.KeyID = base64.URLEncoding.EncodeToString(thumb) - // Marshal private as JSON + // Marshal as JSON payload, err := keyWrapper.MarshalJSON() if err != nil { return "", err diff --git a/pkg/sdk/security/crypto/encoder_test.go b/pkg/sdk/security/crypto/encoder_test.go index 3b3243c1..cdb81c0d 100644 --- a/pkg/sdk/security/crypto/encoder_test.go +++ b/pkg/sdk/security/crypto/encoder_test.go @@ -18,12 +18,123 @@ package crypto import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + cryptorand "crypto/rand" + "crypto/rsa" + "encoding/json" "testing" "github.com/google/go-cmp/cmp" _ "golang.org/x/crypto/blake2b" ) +func TestAlgorithmForECDSACurve(t *testing.T) { + tests := []struct { + name string + curve elliptic.Curve + want string + }{ + { + name: "P-256", + curve: elliptic.P256(), + want: "ES256", + }, + { + name: "P-384", + curve: elliptic.P384(), + want: "ES384", + }, + { + name: "P-521", + curve: elliptic.P521(), + want: "ES512", + }, + { + name: "unsupported curve", + curve: nil, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := algorithmForECDSACurve(tt.curve) + if got != tt.want { + t.Errorf("algorithmForECDSACurve() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAlgorithmForKey(t *testing.T) { + // Generate test keys + rsaPriv, rsaPub, err := generateKeyPair("rsa") + if err != nil { + t.Fatalf("unable to generate rsa key: %v", err) + } + + ecPriv, ecPub, err := generateKeyPair("ec") + if err != nil { + t.Fatalf("unable to generate ec key: %v", err) + } + + edPriv, edPub, err := generateKeyPair("ssh") + if err != nil { + t.Fatalf("unable to generate ed25519 key: %v", err) + } + + tests := []struct { + name string + key interface{} + want string + }{ + { + name: "RSA private key", + key: rsaPriv, + want: "RS256", + }, + { + name: "RSA public key", + key: rsaPub, + want: "RS256", + }, + { + name: "ECDSA private key (P-256)", + key: ecPriv, + want: "ES256", + }, + { + name: "ECDSA public key (P-256)", + key: ecPub, + want: "ES256", + }, + { + name: "Ed25519 private key", + key: edPriv, + want: "EdDSA", + }, + { + name: "Ed25519 public key", + key: edPub, + want: "EdDSA", + }, + { + name: "unsupported key type", + key: "invalid", + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := algorithmForKey(tt.key) + if got != tt.want { + t.Errorf("algorithmForKey() = %v, want %v", got, tt.want) + } + }) + } +} + func TestToJWK(t *testing.T) { priv, pub, err := generateKeyPair("rsa") if err != nil { @@ -64,6 +175,280 @@ func TestToJWK(t *testing.T) { } } +func TestToJWK_AlgorithmField(t *testing.T) { + tests := []struct { + name string + keyType string + wantAlg string + keyModifier func(interface{}, interface{}) interface{} // Optional: modify generated key + }{ + { + name: "RSA private key includes alg=RS256", + keyType: "rsa", + wantAlg: "RS256", + keyModifier: func(priv, pub interface{}) interface{} { + return priv + }, + }, + { + name: "RSA public key includes alg=RS256", + keyType: "rsa", + wantAlg: "RS256", + keyModifier: func(priv, pub interface{}) interface{} { + return pub + }, + }, + { + name: "ECDSA P-256 private key includes alg=ES256", + keyType: "ec", + wantAlg: "ES256", + keyModifier: func(priv, pub interface{}) interface{} { + return priv + }, + }, + { + name: "ECDSA P-256 public key includes alg=ES256", + keyType: "ec", + wantAlg: "ES256", + keyModifier: func(priv, pub interface{}) interface{} { + return pub + }, + }, + { + name: "Ed25519 private key includes alg=EdDSA", + keyType: "ssh", + wantAlg: "EdDSA", + keyModifier: func(priv, pub interface{}) interface{} { + return priv + }, + }, + { + name: "Ed25519 public key includes alg=EdDSA", + keyType: "ssh", + wantAlg: "EdDSA", + keyModifier: func(priv, pub interface{}) interface{} { + return pub + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Generate key pair + pub, priv, err := generateKeyPair(tt.keyType) + if err != nil { + t.Fatalf("unable to generate %s key: %v", tt.keyType, err) + } + + // Select the key to test + var testKey interface{} + if tt.keyModifier != nil { + testKey = tt.keyModifier(priv, pub) + } else { + testKey = priv + } + + // Convert to JWK + jwkJSON, err := ToJWK(testKey) + if err != nil { + t.Fatalf("ToJWK() failed: %v", err) + } + + // Parse JWK JSON to validate alg field + var jwk map[string]interface{} + if err := json.Unmarshal([]byte(jwkJSON), &jwk); err != nil { + t.Fatalf("Failed to parse JWK JSON: %v", err) + } + + // Verify alg field exists + alg, ok := jwk["alg"] + if !ok { + t.Errorf("JWK missing 'alg' field. Got JWK: %s", jwkJSON) + return + } + + // Verify alg field has correct value + algStr, ok := alg.(string) + if !ok { + t.Errorf("'alg' field is not a string. Got type: %T, value: %v", alg, alg) + return + } + + if algStr != tt.wantAlg { + t.Errorf("ToJWK() alg = %v, want %v", algStr, tt.wantAlg) + } + + // Additional validation: verify expected JWK fields + if _, ok := jwk["kty"]; !ok { + t.Errorf("JWK missing 'kty' field") + } + if _, ok := jwk["kid"]; !ok { + t.Errorf("JWK missing 'kid' field") + } + }) + } +} + +func TestToJWK_ECDSACurveVariants(t *testing.T) { + tests := []struct { + name string + curve elliptic.Curve + wantAlg string + }{ + { + name: "P-256 curve produces ES256", + curve: elliptic.P256(), + wantAlg: "ES256", + }, + { + name: "P-384 curve produces ES384", + curve: elliptic.P384(), + wantAlg: "ES384", + }, + { + name: "P-521 curve produces ES512", + curve: elliptic.P521(), + wantAlg: "ES512", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Generate ECDSA key with specific curve using crypto/rand + privKey, err := ecdsa.GenerateKey(tt.curve, cryptorand.Reader) + if err != nil { + t.Fatalf("Failed to generate ECDSA key: %v", err) + } + + // Test private key + jwkJSON, err := ToJWK(privKey) + if err != nil { + t.Fatalf("ToJWK() failed for private key: %v", err) + } + + var jwk map[string]interface{} + if err := json.Unmarshal([]byte(jwkJSON), &jwk); err != nil { + t.Fatalf("Failed to parse JWK JSON: %v", err) + } + + if alg, ok := jwk["alg"].(string); !ok || alg != tt.wantAlg { + t.Errorf("Private key: ToJWK() alg = %v, want %v", jwk["alg"], tt.wantAlg) + } + + // Test public key + jwkJSON, err = ToJWK(&privKey.PublicKey) + if err != nil { + t.Fatalf("ToJWK() failed for public key: %v", err) + } + + if err := json.Unmarshal([]byte(jwkJSON), &jwk); err != nil { + t.Fatalf("Failed to parse JWK JSON: %v", err) + } + + if alg, ok := jwk["alg"].(string); !ok || alg != tt.wantAlg { + t.Errorf("Public key: ToJWK() alg = %v, want %v", jwk["alg"], tt.wantAlg) + } + }) + } +} + +func TestToJWK_RSAKeyTypes(t *testing.T) { + // Generate RSA key + privKey, err := rsa.GenerateKey(cryptorand.Reader, 2048) + if err != nil { + t.Fatalf("Failed to generate RSA key: %v", err) + } + + tests := []struct { + name string + key interface{} + }{ + { + name: "RSA private key", + key: privKey, + }, + { + name: "RSA public key", + key: &privKey.PublicKey, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + jwkJSON, err := ToJWK(tt.key) + if err != nil { + t.Fatalf("ToJWK() failed: %v", err) + } + + var jwk map[string]interface{} + if err := json.Unmarshal([]byte(jwkJSON), &jwk); err != nil { + t.Fatalf("Failed to parse JWK JSON: %v", err) + } + + // Verify alg field + if alg, ok := jwk["alg"].(string); !ok || alg != "RS256" { + t.Errorf("ToJWK() alg = %v, want RS256", jwk["alg"]) + } + + // Verify kty field + if kty, ok := jwk["kty"].(string); !ok || kty != "RSA" { + t.Errorf("ToJWK() kty = %v, want RSA", jwk["kty"]) + } + }) + } +} + +func TestToJWK_Ed25519KeyTypes(t *testing.T) { + // Generate Ed25519 key + pubKey, privKey, err := ed25519.GenerateKey(cryptorand.Reader) + if err != nil { + t.Fatalf("Failed to generate Ed25519 key: %v", err) + } + + tests := []struct { + name string + key interface{} + }{ + { + name: "Ed25519 private key", + key: privKey, + }, + { + name: "Ed25519 public key", + key: pubKey, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + jwkJSON, err := ToJWK(tt.key) + if err != nil { + t.Fatalf("ToJWK() failed: %v", err) + } + + var jwk map[string]interface{} + if err := json.Unmarshal([]byte(jwkJSON), &jwk); err != nil { + t.Fatalf("Failed to parse JWK JSON: %v", err) + } + + // Verify alg field + if alg, ok := jwk["alg"].(string); !ok || alg != "EdDSA" { + t.Errorf("ToJWK() alg = %v, want EdDSA", jwk["alg"]) + } + + // Verify kty field + if kty, ok := jwk["kty"].(string); !ok || kty != "OKP" { + t.Errorf("ToJWK() kty = %v, want OKP", jwk["kty"]) + } + + // Verify crv field + if crv, ok := jwk["crv"].(string); !ok || crv != "Ed25519" { + t.Errorf("ToJWK() crv = %v, want Ed25519", jwk["crv"]) + } + }) + } +} + func TestToPEM(t *testing.T) { rsaPriv, rsaPub, err := generateKeyPair("rsa") if err != nil {