$ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot $document = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json $yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.yaml') $issues = New-Object System.Collections.Generic.List[string] $passwordPath = '/genealogy/app/auth/password' function Add-Issue { param([string]$Message) $script:issues.Add($Message) } function Get-Schema { param([string]$Name) $property = $document.components.schemas.PSObject.Properties[$Name] if (-not $property) { Add-Issue "JSON missing schema owner: $Name" return $null } return $property.Value } function Get-Response { param([object]$Operation, [string]$Status) if (-not $Operation) { return $null } $property = $Operation.responses.PSObject.Properties[$Status] if (-not $property) { Add-Issue "JSON PUT $passwordPath missing $Status response" return $null } $response = $property.Value if ($response.'$ref') { $name = ([string]$response.'$ref').Split('/')[-1] $owner = $document.components.responses.PSObject.Properties[$name] if (-not $owner) { Add-Issue "JSON missing response owner: $name" return $null } $response = $owner.Value } return $response } function Get-JsonSchemaRef { param([object]$Response, [string]$Status) if (-not $Response) { return '' } $media = $Response.content.PSObject.Properties['application/json'] if (-not $media) { Add-Issue "JSON PUT $passwordPath $Status must use application/json" return '' } return [string]$media.Value.schema.'$ref' } function Assert-PrivateNoStore { param([object]$Response, [string]$Status) if (-not $Response) { return } $property = if ($Response.headers) { $Response.headers.PSObject.Properties['Cache-Control'] } else { $null } if (-not $property) { Add-Issue "JSON PUT $passwordPath $Status must document Cache-Control: private, no-store" return } $header = $property.Value if ($header.'$ref') { $name = ([string]$header.'$ref').Split('/')[-1] $owner = $document.components.headers.PSObject.Properties[$name] if ($owner) { $header = $owner.Value } } $evidence = ([string]$header.description) + ' ' + ([string]$header.example) + ' ' + ([string]$header.schema.example) if ($header.schema.type -ne 'string' -or $evidence -notmatch '(?i)(private.*no-store|no-store.*private)') { Add-Issue "JSON PUT $passwordPath $Status Cache-Control must specify private, no-store" } } function Assert-SecretSchema { param( [object]$Schema, [string]$Name, [int]$Minimum, [int]$Maximum ) if (-not $Schema) { return } if ($Schema.type -ne 'string' -or $Schema.format -ne 'password' -or $Schema.writeOnly -ne $true -or [int]$Schema.minLength -ne $Minimum -or [int]$Schema.maxLength -ne $Maximum) { Add-Issue "JSON $Name must be a writeOnly password string of $Minimum..$Maximum Unicode code points" } if ($Schema.pattern -or $Schema.example -or ([string]$Schema.description) -match '(?i)MD5|hex|字母.*数字|数字.*字母') { Add-Issue "JSON $Name must not retain a static digest, composition rule, pattern, or password example" } if (([string]$Schema.description) -notmatch '(?i)Unicode code point' -or ([string]$Schema.description) -notmatch '(?i)NFC' -or ([string]$Schema.description) -notmatch '(?i)(space|空格)') { Add-Issue "JSON $Name must define Unicode code-point length, NFC normalization, and space handling" } } function Assert-FieldRef { param([string]$SchemaName, [string]$Field, [string]$ExpectedRef) $schema = Get-Schema $SchemaName if (-not $schema) { return } $property = $schema.properties.PSObject.Properties[$Field] $actual = if ($property) { [string]$property.Value.'$ref' } else { '' } if ($actual -ne $ExpectedRef) { Add-Issue "JSON $SchemaName.$Field must use $ExpectedRef; actual: $actual" } } $pathProperty = $document.paths.PSObject.Properties[$passwordPath] $operation = if ($pathProperty) { $pathProperty.Value.put } else { $null } if (-not $operation) { Add-Issue "JSON missing PUT $passwordPath" } if ($operation) { $hasSaToken = $false foreach ($requirement in @($operation.security)) { if ($requirement.PSObject.Properties.Name -contains 'SaToken') { $hasSaToken = $true } } if (-not $hasSaToken) { Add-Issue "JSON PUT $passwordPath must require SaToken" } $clientHeaders = @($operation.parameters | Where-Object { $_.name -eq 'clientid' -and $_.in -eq 'header' }) if ($clientHeaders.Count -ne 1 -or $clientHeaders[0].required -ne $true -or $clientHeaders[0].schema.type -ne 'string' -or [int]$clientHeaders[0].schema.minLength -lt 1) { Add-Issue "JSON PUT $passwordPath must require one non-empty string clientid header" } $requestMedia = $operation.requestBody.content.PSObject.Properties['application/json'] if ($operation.requestBody.required -ne $true -or -not $requestMedia) { Add-Issue "JSON PUT $passwordPath must require an application/json body" } elseif ($requestMedia.Value.schema.'$ref' -ne '#/components/schemas/PasswordChangeBody') { Add-Issue 'JSON password change request must use PasswordChangeBody' } $semantics = [string]$operation.description foreach ($semanticPattern in @( '(?i)current password.*re-authentication', '(?i)atomic.*password.*credential epoch', '(?i)all.*access.*refresh.*sessions.*including.*current', '(?i)200.*sessions.*invalidated', '(?i)new password.*different.*current password', '(?i)(common|breached) password.*blocklist', '(?i)rate limit' )) { if ($semantics -notmatch $semanticPattern) { Add-Issue "JSON PUT $passwordPath description is missing security/session semantics: $semanticPattern" } } foreach ($status in @('200', '400', '401', '409', '422', '429', '500')) { if (-not $operation.responses.PSObject.Properties[$status]) { Add-Issue "JSON PUT $passwordPath missing documented response: $status" } } } $changeBody = Get-Schema 'PasswordChangeBody' $currentSecret = Get-Schema 'CurrentPasswordSecret' $newSecret = Get-Schema 'NewPasswordSecret' Assert-SecretSchema $currentSecret 'CurrentPasswordSecret' 1 64 Assert-SecretSchema $newSecret 'NewPasswordSecret' 15 64 if ($changeBody) { $properties = @($changeBody.properties.PSObject.Properties.Name | Sort-Object) $required = @($changeBody.required | Sort-Object) if ($changeBody.type -ne 'object' -or $changeBody.additionalProperties -ne $false -or ($properties -join ',') -ne 'newPassword,oldPassword' -or ($required -join ',') -ne 'newPassword,oldPassword') { Add-Issue 'JSON PasswordChangeBody must be a closed object requiring only oldPassword/newPassword' } } # 密码传输是跨登录、注册、找回和登录态改密的单一合同。禁止只让 M04 改成明文, # 其余入口继续接受可重放摘要;新合同落地时必须一次删除全部 MD5 wire fallback。 Assert-FieldRef 'PasswordLoginBody' 'password' '#/components/schemas/CurrentPasswordSecret' Assert-FieldRef 'PasswordRegisterBody' 'password' '#/components/schemas/NewPasswordSecret' Assert-FieldRef 'PasswordResetBody' 'newPassword' '#/components/schemas/NewPasswordSecret' Assert-FieldRef 'PasswordChangeBody' 'oldPassword' '#/components/schemas/CurrentPasswordSecret' Assert-FieldRef 'PasswordChangeBody' 'newPassword' '#/components/schemas/NewPasswordSecret' $responses = @{} foreach ($status in @('200', '400', '401', '409', '422', '429', '500')) { $responses[$status] = Get-Response $operation $status [void](Get-JsonSchemaRef $responses[$status] $status) Assert-PrivateNoStore $responses[$status] $status } if ((Get-JsonSchemaRef $responses['200'] '200') -ne '#/components/schemas/RVoid') { Add-Issue 'JSON PUT password 200 must return RVoid after all sessions are invalidated' } foreach ($status in @('409', '422')) { if ((Get-JsonSchemaRef $responses[$status] $status) -ne '#/components/schemas/RPasswordChangeRejected') { Add-Issue "JSON PUT password $status must return RPasswordChangeRejected" } } $retryAfterProperty = if ($responses['429'] -and $responses['429'].headers) { $responses['429'].headers.PSObject.Properties['Retry-After'] } else { $null } if (-not $retryAfterProperty) { Add-Issue 'JSON PUT password 429 must document Retry-After' } $void = Get-Schema 'RVoid' $rejected = Get-Schema 'RPasswordChangeRejected' if ($void -and ('code' -notin @($void.required) -or $void.properties.code.type -ne 'integer')) { Add-Issue 'JSON RVoid must require integer code' } if ($rejected) { foreach ($field in @('code', 'businessCode')) { if ($field -notin @($rejected.required)) { Add-Issue "JSON RPasswordChangeRejected.required missing: $field" } } $codes = @($rejected.properties.businessCode.enum | Sort-Object) $expectedCodes = @( 'CREDENTIAL_VERSION_CONFLICT', 'CURRENT_PASSWORD_INCORRECT', 'NEW_PASSWORD_SAME_AS_CURRENT', 'PASSWORD_POLICY_VIOLATION' ) | Sort-Object if ($rejected.properties.code.type -ne 'integer' -or $rejected.properties.businessCode.type -ne 'string' -or ($codes -join ',') -ne ($expectedCodes -join ',')) { Add-Issue 'JSON RPasswordChangeRejected must expose the four stable conflict/validation business codes' } } foreach ($yamlFact in @( ' /genealogy/app/auth/password:', ' name: clientid', '#/components/schemas/PasswordChangeBody', '#/components/schemas/CurrentPasswordSecret', '#/components/schemas/NewPasswordSecret', '#/components/schemas/RPasswordChangeRejected', ' CurrentPasswordSecret:', ' NewPasswordSecret:', ' writeOnly: true', ' minLength: 15', ' maxLength: 64', ' RPasswordChangeRejected:', ' - CREDENTIAL_VERSION_CONFLICT', ' - CURRENT_PASSWORD_INCORRECT', ' - NEW_PASSWORD_SAME_AS_CURRENT', ' - PASSWORD_POLICY_VIOLATION', ' Cache-Control:', ' Retry-After:' )) { if (-not $yaml.Contains($yamlFact)) { Add-Issue "YAML fact is missing: $yamlFact" } } if ($issues.Count -gt 0) { $lines = New-Object System.Collections.Generic.List[string] $lines.Add('PASSWORD-CHANGE-OPENAPI-CONTRACT BLOCKED') foreach ($issue in $issues) { $lines.Add("- $issue") } $lines.Add('- Remove static MD5 from login/register/reset/change in one contract migration; accept raw writeOnly passwords only over authenticated HTTPS and store a salted adaptive server-side hash.') $lines.Add('- The target new-password policy is 15..64 Unicode code points, NFC, spaces allowed, no composition rule, plus server-side common/breached-password blocklist and rate limiting.') $lines.Add('- A strict 200 means the password is durable and every pre-existing access/refresh session, including the caller, is invalidated; the client clears locally and returns to A01.') $lines.Add('- Network, timeout, malformed response, or 5xx after dispatch is outcome-unknown: clear secrets/session, return to A01, and never retry automatically or claim success.') $lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.') throw ($lines -join [Environment]::NewLine) } Write-Output 'PASSWORD-CHANGE-OPENAPI-CONTRACT PASS'