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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .agents/skills/a2a-workflow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,26 @@ ISafeguardA2AContext fromKeystore = Safeguard.A2A.getContext(
Windows thumbprint overloads require `SunMSCAPI` to be available. The SDK throws a
`SafeguardForJavaException` on non-Windows platforms or when the provider is missing.

### TLS 1.3, the Cert SNI hostname, and the JSSE post-handshake limitation

A2A is certificate-authenticated, so TLS version matters. SafeguardJava negotiates
**TLS 1.2 only by default**. On Safeguard 9.0 (which enables TLS 1.3), A2A/cert-auth
over TLS 1.3 fails on the **Standard binding** with `60094 Authorization is denied`,
because the server requests the client certificate *post-handshake* (RFC 8446
§4.6.2) and Java's JSSE never presents a certificate in response. This is a Java
**platform** limitation (verified on JDK 11 and JDK 21), not an SDK bug — no
SafeguardJava setting or JVM flag makes post-handshake client auth work. TLS 1.2
keeps working because the certificate request happens differently.

- **Default (TLS 1.2):** A2A works against 9.0 with no extra configuration.
- **TLS 1.3 A2A/cert-auth:** connect to the appliance **Cert SNI hostname**, where
the certificate is requested *in-handshake*. Then opt into 1.3:
`Safeguard.setMaxTlsVersion(TlsVersion.TLSv1_3)` (or
`setMinTlsVersion(TlsVersion.TLSv1_3)` to require it), or the
`safeguard.tls.min/maxVersion` system properties. Configure this **before**
calling `getContext(...)`; it is process-wide.
- Requests stay on **HTTP/1.1** (HTTP/2 disallows the post-handshake request).

## 3. Credential retrieval (programmatic access)

### Enumerate retrievable accounts
Expand Down Expand Up @@ -266,4 +286,5 @@ Troubleshooting checklist:
4. use `getRetrievableAccounts()` to prove what the certificate can actually see
5. switch from a transient listener to a persistent listener if outages matter
6. avoid `ignoreSsl=true` outside lab scenarios
7. clear API keys, passwords, and retrieved secrets from memory when finished
7. on Safeguard 9.0, if cert-auth returns `60094 Authorization is denied` only when TLS 1.3 is negotiated, use the Cert SNI hostname for 1.3 or keep the default TLS 1.2 (JSSE cannot present a client cert post-handshake)
8. clear API keys, passwords, and retrieved secrets from memory when finished
11 changes: 11 additions & 0 deletions .agents/skills/api-patterns/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,17 @@ If you expect a long-running process, wrap the connection with `Safeguard.Persis
`PersistentSafeguardConnection` checks `getAccessTokenLifetimeRemaining()` before each
`invokeMethod*` call and refreshes expired tokens automatically.

### TLS version (default 1.2, opt-in 1.3)

All connections negotiate **TLS 1.2 only** by default. Password/token auth can safely
use TLS 1.3 on the Standard binding; certificate/A2A auth over TLS 1.3 requires the
appliance **Cert SNI hostname** because JSSE cannot present a client certificate
post-handshake. Opt into 1.3 process-wide **before** calling `connect(...)`:
`Safeguard.setMaxTlsVersion(TlsVersion.TLSv1_3)` /
`Safeguard.setMinTlsVersion(TlsVersion.TLSv1_3)`, or the
`safeguard.tls.min/maxVersion` system properties. See the README "TLS Protocol
Versions" section and the `a2a-workflow` skill for the full rationale.

### Management service calls

`Service.Management` is only valid on a management connection:
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ See `testing-guide` for setup and workflow details.
- expect `ArgumentException`, `SafeguardForJavaException`, and `ObjectDisposedException`
- preserve Java 8 compatibility and standard Java naming
- do not recommend `ignoreSsl=true` for production without a warning
- default TLS is **1.2 only** across all transports; TLS 1.3 is opt-in via `Safeguard.setMin/MaxTlsVersion(TlsVersion)` or `safeguard.tls.min/maxVersion` system properties. JSSE has no client post-handshake auth, so TLS 1.3 cert/A2A auth requires the appliance Cert SNI hostname
- keep repository text files on **LF** line endings, especially on Windows

## CI/CD
Expand Down
78 changes: 71 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,11 +301,12 @@ public class CertificateValidator implements HostnameVerifier {
### TLS Certificate Verification and the `ignoreSsl` Flag

Every `Safeguard.connect` / `Safeguard.A2A.GetContext` overload accepts an
`ignoreSsl` (`boolean`) parameter. The SDK pins the minimum TLS version to
**TLS 1.2** in all transports (REST and SignalR), regardless of this flag —
weak TLS versions are never negotiated. What `ignoreSsl` controls is
**X.509 certificate chain validation**, not the TLS version and not hostname
verification on its own.
`ignoreSsl` (`boolean`) parameter. By default the SDK negotiates **TLS 1.2**
in all transports (REST and SignalR); weaker versions (TLS 1.0/1.1) are never
enabled, and TLS 1.3 is available as an opt-in (see
[TLS Protocol Versions](#tls-protocol-versions-tls-13-support) below). What
`ignoreSsl` controls is **X.509 certificate chain validation**, not the TLS
version and not hostname verification on its own.

| Setting | Chain validation | Hostname verification | Recommended use |
|---|---|---|---|
Expand All @@ -331,6 +332,69 @@ the flag is an explicit opt-in — by the time a caller passes `true`, the
trade-off has already been accepted. The responsibility for production
hardening lies with the integrating application.

### TLS Protocol Versions (TLS 1.3 Support)

Safeguard 9.0 (Windows 11 base OS) enables **TLS 1.3**. By default SafeguardJava
negotiates **TLS 1.2 only** across every transport (REST and SignalR). This is a
deliberate default, not just legacy behavior:

> **⚠️ Java limitation — no TLS 1.3 post-handshake client authentication.**
> Java's TLS engine (JSSE) **does not** present a client certificate in response
> to a TLS 1.3 post-handshake `CertificateRequest` (RFC 8446 §4.6.2). This has
> been verified on JDK 11 and JDK 21 and is a limitation of the Java platform
> itself, **not** of this SDK — there is no SafeguardJava setting or JVM flag that
> makes it work. As a direct consequence, **certificate-based and A2A
> authentication cannot use TLS 1.3 on the appliance Standard binding**; they must
> either run over TLS 1.2 (the default) or connect to the appliance **Cert SNI
> hostname** (see below). Password/token authentication is unaffected.

- **JSSE cannot present a client certificate post-handshake.** On TLS 1.3 with
the appliance **Standard binding**, the server requests the client certificate
*after* the handshake (post-handshake authentication, RFC 8446 §4.6.2). Java's
JSSE never answers that request, so certificate/A2A authentication fails on a
TLS 1.3 connection (`60094 Authorization is denied`) while succeeding on
TLS 1.2. Keeping the default at TLS 1.2 keeps cert-auth working out of the box.
- **Password/token authentication** carries no client certificate and can use
TLS 1.3 on the Standard binding without issue.
- **The only route to TLS 1.3 certificate/A2A auth** with this SDK is to connect
to the appliance **Cert SNI hostname**, where the certificate is requested
*in-handshake* (no post-handshake step). Password auth can also use it.
- Requests use **HTTP/1.1** (HTTP/2 disallows the post-handshake
`CertificateRequest`); this is unchanged.

You can raise (or pin) the allowed versions with an opt-in minimum/maximum bound.
The setting is process-wide and read when each connection or listener is created,
so configure it **before** calling `Safeguard.connect(...)`:

```java
import com.oneidentity.safeguard.safeguardjava.Safeguard;
import com.oneidentity.safeguard.safeguardjava.TlsVersion;

// Allow TLS 1.3 in addition to 1.2 (e.g. password/token auth on the Standard
// binding, or cert-auth against the Cert SNI hostname):
Safeguard.setMaxTlsVersion(TlsVersion.TLSv1_3);

// Require TLS 1.3 only:
Safeguard.setMinTlsVersion(TlsVersion.TLSv1_3);

// Restore the default (TLS 1.2 only):
Safeguard.setMaxTlsVersion(null);
```

For an interim rollout with no code change, the same bounds can be supplied as
JVM system properties (programmatic settings take precedence):

```
-Dsafeguard.tls.minVersion=TLSv1.2 -Dsafeguard.tls.maxVersion=TLSv1.3
```

| Configuration | Enabled versions |
|---|---|
| Default (both unset) | `TLSv1.2` |
| `setMaxTlsVersion(TLSv1_3)` | `TLSv1.2`, `TLSv1.3` |
| `setMinTlsVersion(TLSv1_3)` | `TLSv1.3` |
| `setMin/MaxTlsVersion(TLSv1_2)` | `TLSv1.2` |

### Installation

SafeguardJava is available from [Maven Central](https://central.sonatype.com/artifact/com.oneidentity.safeguard/safeguardjava)
Expand All @@ -343,7 +407,7 @@ available for direct download from [GitHub Releases](https://github.com/OneIdent
<dependency>
<groupId>com.oneidentity.safeguard</groupId>
<artifactId>safeguardjava</artifactId>
<version>7.5.0</version>
<version>8.4.0</version>
</dependency>
```

Expand All @@ -360,7 +424,7 @@ available for direct download from [GitHub Releases](https://github.com/OneIdent
<dependency>
<groupId>com.oneidentity.safeguard</groupId>
<artifactId>safeguardjava</artifactId>
<version>7.5.0</version>
<version>8.4.0</version>
</dependency>
```

Expand Down
12 changes: 12 additions & 0 deletions TestFramework/Invoke-SafeguardTests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@
.PARAMETER TestPrefix
Prefix for test objects created on the appliance. Default: "SgJTest".

.PARAMETER CertSniHost
Appliance Cert SNI hostname. When supplied, enables the certificate-auth-over-
TLS-1.3 test (JSSE cannot present a client certificate post-handshake, so cert
auth over TLS 1.3 requires the appliance Cert SNI binding). Skipped when unset.

.PARAMETER MavenCmd
Path to the Maven command. Defaults to searching PATH then common locations.

Expand Down Expand Up @@ -101,6 +106,9 @@ param(
[Parameter()]
[string]$TestPrefix = "SgJTest",

[Parameter()]
[string]$CertSniHost,

[Parameter()]
[string]$MavenCmd
)
Expand Down Expand Up @@ -233,6 +241,9 @@ if ($Pkce) {
if ($SpsAppliance) {
Write-Host " SPS: $SpsAppliance" -ForegroundColor White
}
if ($CertSniHost) {
Write-Host " Cert SNI: $CertSniHost" -ForegroundColor White
}
Write-Host ("=" * 66) -ForegroundColor Cyan

$context = New-SgJTestContext `
Expand All @@ -243,6 +254,7 @@ $context = New-SgJTestContext `
-SpsUserName $SpsUserName `
-SpsPassword $SpsPassword `
-TestPrefix $TestPrefix `
-CertSniHost $CertSniHost `
-MavenCmd $MavenCmd `
-Pkce:$Pkce

Expand Down
87 changes: 85 additions & 2 deletions TestFramework/SafeguardTestFramework.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ function New-SgJTestContext {
[Parameter()]
[string]$MavenCmd,

[Parameter()]
[string]$CertSniHost,

[Parameter()]
[switch]$Pkce
)
Expand All @@ -66,6 +69,9 @@ function New-SgJTestContext {
AdminUserName = $AdminUserName
AdminPassword = $AdminPassword

# Appliance Cert SNI hostname (optional; enables cert-auth-over-TLS-1.3 tests)
CertSniHost = $CertSniHost

# SPS connection info
SpsAppliance = $SpsAppliance
SpsUserName = $SpsUserName
Expand Down Expand Up @@ -365,13 +371,25 @@ function Invoke-SgJSafeguardApi {
[Parameter()]
[hashtable]$Parameters,

[Parameter()]
[ValidateSet("1.2", "1.3")]
[string]$MinTlsVersion,

[Parameter()]
[ValidateSet("1.2", "1.3")]
[string]$MaxTlsVersion,

[Parameter()]
[string]$ApplianceOverride,

[Parameter()]
[bool]$ParseJson = $true
)

if (-not $Context) { $Context = Get-SgJTestContext }

$toolArgs = "-a $($Context.Appliance) -x -s $Service -m $Method -U `"$RelativeUrl`""
$applianceHost = if ($ApplianceOverride) { $ApplianceOverride } else { $Context.Appliance }
$toolArgs = "-a $applianceHost -x -s $Service -m $Method -U `"$RelativeUrl`""

$stdinLine = $null

Expand Down Expand Up @@ -421,6 +439,9 @@ function Invoke-SgJSafeguardApi {
$toolArgs += " -P `"$paramPairs`""
}

if ($MinTlsVersion) { $toolArgs += " --min-tls $MinTlsVersion" }
if ($MaxTlsVersion) { $toolArgs += " --max-tls $MaxTlsVersion" }

Write-Verbose "Invoke-SgJSafeguardApi: $toolArgs"

return Invoke-SgJSafeguardTool -Arguments $toolArgs -StdinLine $stdinLine -ParseJson $ParseJson
Expand Down Expand Up @@ -1155,14 +1176,75 @@ function Build-SgJTestProjects {
throw "SDK build failed: $result"
}

# Read the SDK's CI-friendly <revision> so the tool links exactly what was
# just built, regardless of what version the SDK pom is currently at.
$sdkRevision = $null
$rootPom = Get-Content "$($Context.RepoRoot)/pom.xml" -Raw
if ($rootPom -match '<revision>\s*([^<]+?)\s*</revision>') {
$sdkRevision = $Matches[1].Trim()
}

# Build and package the test tool
Write-Host " Building test tool..." -ForegroundColor DarkGray
$result = & $mvn -f "$($Context.ToolDir)/pom.xml" clean package -q 2>&1
$toolArgs = @('-f', "$($Context.ToolDir)/pom.xml", 'clean', 'package', '-q')
if ($sdkRevision) {
$toolArgs += "-Drevision=$sdkRevision"
}
$result = & $mvn @toolArgs 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Test tool build failed: $result"
}
}

function Test-SgJApplianceTls13 {
<#
.SYNOPSIS
Returns $true if the appliance negotiates TLS 1.3, $false otherwise.

.DESCRIPTION
Performs a raw TLS 1.3-only handshake against the appliance so callers can
assert the SDK's behavior against the server's real capability. SPP 8.x tops
out at TLS 1.2; SPP 9.0+ negotiates TLS 1.3. Certificate validation is skipped
because only the negotiated protocol version matters here.

.PARAMETER ApplianceHost
Appliance hostname or IP address.

.PARAMETER Port
HTTPS port. Defaults to 443.

.EXAMPLE
if (Test-SgJApplianceTls13 -ApplianceHost $ctx.Appliance) { ... }
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, Position = 0)]
[string]$ApplianceHost,

[Parameter()]
[int]$Port = 443
)

$tcp = [System.Net.Sockets.TcpClient]::new()
try {
$tcp.Connect($ApplianceHost, $Port)
$noValidation = [System.Net.Security.RemoteCertificateValidationCallback] { param($s, $c, $ch, $e) $true }
$ssl = [System.Net.Security.SslStream]::new($tcp.GetStream(), $false, $noValidation)
try {
$authOpts = [System.Net.Security.SslClientAuthenticationOptions]::new()
$authOpts.TargetHost = $ApplianceHost
$authOpts.EnabledSslProtocols = [System.Security.Authentication.SslProtocols]::Tls13
$ssl.AuthenticateAsClient($authOpts)
return ($ssl.SslProtocol -eq [System.Security.Authentication.SslProtocols]::Tls13)
}
finally { $ssl.Dispose() }
}
catch {
return $false
}
finally { $tcp.Dispose() }
}

# ============================================================================
# Exports
# ============================================================================
Expand Down Expand Up @@ -1207,4 +1289,5 @@ Export-ModuleMember -Function @(

# Build
'Build-SgJTestProjects'
'Test-SgJApplianceTls13'
)
24 changes: 24 additions & 0 deletions TestFramework/Suites/Suite-CertificateAuth.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
if ($Context.SuiteData["Skipped"]) {
Test-SgJSkip "Auth as cert user from PFX file" "Test certificates not found"
Test-SgJSkip "Cert user identity matches expected name" "Test certificates not found"
Test-SgJSkip "Auth as cert user over TLS 1.3 (Cert SNI)" "Test certificates not found"
return
}

Expand Down Expand Up @@ -110,6 +111,29 @@
-CertificateFile $Context.UserPfx -CertificatePassword "a" -Full
$result.StatusCode -eq 200
}

# Certificate authentication over TLS 1.3. JSSE cannot present a client
# certificate in response to a TLS 1.3 post-handshake CertificateRequest, so
# cert auth over TLS 1.3 only succeeds against the appliance Cert SNI binding,
# where the certificate is requested in-handshake. Requires an admin-configured
# Cert SNI hostname; skipped unless -CertSniHost is supplied.
if (-not $Context.CertSniHost) {
Test-SgJSkip "Auth as cert user over TLS 1.3 (Cert SNI)" `
"No -CertSniHost specified"
}
elseif (-not (Test-SgJApplianceTls13 -ApplianceHost $Context.CertSniHost)) {
Test-SgJSkip "Auth as cert user over TLS 1.3 (Cert SNI)" `
"Cert SNI host $($Context.CertSniHost) does not negotiate TLS 1.3"
}
else {
Test-SgJAssert "Auth as cert user over TLS 1.3 (Cert SNI)" {
$result = Invoke-SgJSafeguardApi -Context $Context `
-Service Core -Method Get -RelativeUrl "Me" `
-CertificateFile $Context.UserPfx -CertificatePassword "a" `
-ApplianceOverride $Context.CertSniHost -MinTlsVersion 1.3
$null -ne $result -and $result.Name -eq $Context.SuiteData["CertUserName"]
}
}
}

Cleanup = {
Expand Down
Loading