---
title: De-salt and validate credential lifetime
summary: Recover the identity hash from a previously issued credential and reject it if expired or not yet valid.
status: draft
owner: SAIF Platform Team
last_reviewed: 2026-10-02
library: SAIF.NetCore.SystemHash
reference_type: package
operation_id: OP-SH-06
trigger: A validating service receives a salted credential and must check its freshness before trusting it.
---

# De-salt and validate credential lifetime

Recovers the embedded create-timestamp and unsalted identity hash from a credential previously issued by [Compute a salted system hash](compute-salted-system-hash.md), and rejects the credential if it falls outside the allowed lifetime window.

| Field | Value |
| --- | --- |
| **Operation ID** | OP-SH-06 |
| **Trigger** | Direct call by a validating service (exercised in-repo only by this library's unit tests). |
| **Inputs** | `systemHashValue` (salted credential), `expireSeconds`, `nowBufferSeconds` |
| **Outputs** | Unsalted 44-char identity hash, or a thrown `SystemHashExpiredException` |
| **Primary components** | `SystemHashService.RemoveSystemHashSaltAndValidateLifetime` |
| **Entry point** | `src/SAIF.NetCore.SystemHash/SAIF.NetCore.SystemHash/SAIF.NetCore.SystemHash/SystemHashService.cs` |

## Steps

1. Construct a `SaltShaker` using the parameterless constructor. This only creates a new `System.Random` instance and leaves `_dateParts` `null`; it does **not** read, parse, or otherwise touch `systemHashValue` — parsing begins only in the next step, inside `BlandHash`.

   > **Origin**: This step is implemented in [Construct a de-salting SaltShaker](../../saif-netcore-hashutil/operations/op-hu-02-construct-desalting-saltshaker.md) (`SAIF.NetCore.HashUtil`) and executed on behalf of this operation.
2. Call `BlandHash(systemHashValue, out createDate)` to parse and de-salt the credential, recovering the embedded create timestamp and the plain identity hash ([BR-SH-03](../business-rules.md#br-sh-03-credential-embeds-creation-time)).
   - Sub-steps embedded from `SAIF.NetCore.HashUtil`:
     - Take the first 42 characters of `systemHashValue` as the location map.
     - Split the map into 6-character chunks and reverse their order, producing entries in Month → Minute order.
     - Remove every occurrence of the map string from `systemHashValue` (a full-string `Replace`, not a single removal).
     - For each date part in Month, Hour, Second, Millisecond, Day, Year, Minute order: parse the 3-digit insertion index from the corresponding map entry (ignoring the pad character and body-length digits also recorded in that entry), read the correct number of characters (4, 6, or 8, depending on the part) at that index, hex-decode them, and remove those characters from the working string.
     - Parse the reconstructed `"yyyy/MM/dd HH:mm:ss.fff"` string into a `DateTime` using the current culture (`Convert.ToDateTime`).
     - On a malformed input: raise `ArgumentOutOfRangeException`, `FormatException`, or `NullReferenceException` — not `SystemHashExpiredException` — because this method performs no try/catch around the call ([BR-SH-09](../business-rules.md#br-sh-09-malformed-credential-raises-bcl-exceptions)).

     > **Origin**: The sub-steps above are implemented in [De-salt a system hash and recover the embedded date](../../saif-netcore-hashutil/operations/op-hu-04-de-salt-a-system-hash.md) (`SAIF.NetCore.HashUtil`) and executed on behalf of this operation.
3. Compare the recovered create timestamp against the allowed lifetime window: valid only if `createDate ≤ now + abs(nowBufferSeconds)` AND `now − abs(nowBufferSeconds) < createDate + expireSeconds` ([BR-SH-08](../business-rules.md#br-sh-08-credential-lifetime-window)). This simplified predicate holds for every `nowBufferSeconds` value except `int.MinValue`, provided the date shifts stay within the `DateTime` range. The literal source calls `createDate.AddSeconds(expireSeconds)` and `now.AddSeconds(...)` directly, and those calls throw `ArgumentOutOfRangeException` if a shifted value falls outside the `DateTime` range. It also computes `nowBufferSeconds * -1` in an unchecked integer context: the project does not set `CheckForOverflowUnderflow` and the method has no `checked` block. That negation never throws `OverflowException`. At `int.MinValue` it wraps silently back to `int.MinValue`, so both arms of each bound check shift `now` back by about 68 years and the window is no longer symmetric — but the method does not special-case this value; it is still evaluated through the same shifted-window predicate as any other `nowBufferSeconds`. A current credential's `createDate` still fails that shifted predicate and is rejected, but the outcome is conditional, not unconditional: a credential with a `createDate` roughly 68+ years in the past, combined with a sufficiently large `expireSeconds`, can satisfy the shifted predicate and pass the check.
   - If the window check fails:
     - Raise a `SystemHashExpiredException(now, createDate)`, unless a `DateTime.AddSeconds` call above has already thrown `ArgumentOutOfRangeException` because a shifted date fell outside the `DateTime` range. A `nowBufferSeconds` of `int.MinValue` does not throw — the negation wraps silently — and this path is reached the same way as any other failing `nowBufferSeconds`: a current credential is rejected through this `SystemHashExpiredException` path, but a crafted old credential (see step 3) can instead satisfy the window check and avoid this path entirely.

     > **Origin**: The exception type and its construction semantics are implemented in [Signal an expired system hash](../../saif-netcore-hashutil/operations/op-hu-05-signal-expired-system-hash.md) (`SAIF.NetCore.HashUtil`) and raised on behalf of this operation.
4. If the window check succeeds, return the unsalted identity hash recovered in step 2 ([BR-SH-10](../business-rules.md#br-sh-10-lifetime-validation-is-not-identity)).
   - The caller — not this operation — is responsible for comparing the returned hash against an authorized/`ComputeHashNow` value; this operation only enforces freshness.

## Process flow

The following diagram shows the lifetime-check path from a received salted credential to acceptance, expiry, or an uncaught date-range failure. The method checks freshness only; it does not issue credentials or compare against an external identity. If `createDate.AddSeconds(expireSeconds)` falls outside the representable `DateTime` range, `DateTime.AddSeconds` throws `ArgumentOutOfRangeException` before the window predicate is evaluated. That exception propagates uncaught rather than becoming `SystemHashExpiredException`. The `nowBufferSeconds * -1` negation never throws, because integer arithmetic here is unchecked. At `int.MinValue` it wraps back to `int.MinValue`, so both bound checks shift `now` back by about 68 years. The method does not special-case this value in code, but because both arms of the window check then collapse onto the same shifted value, the diagram shows it as its own comparison, `shiftedNow = now.AddSeconds(int.MinValue); shiftedNow >= createDate && shiftedNow < expires`, rather than the simplified symmetric predicate used for every other `nowBufferSeconds`. A current credential still fails that shifted comparison and is rejected, but the outcome is conditional, not unconditional: a credential with a `createDate` far enough in the past, combined with a sufficiently large `expireSeconds`, can satisfy the shifted predicate and be accepted.

```mermaid
flowchart TD
	Start([Validator receives salted credential]) --> ConstructDesalt[Construct de-salting SaltShaker]
	ConstructDesalt --> Desalt["De-salt: recover createDate and unsalted hash"]
	Desalt --> Malformed{Well-formed HashUtil format?}
	Malformed -->|No| BclError(["BCL exception propagates: Argument / Format / NullReference"])
	Malformed -->|Yes| Domain{"createDate + expireSeconds<br>within DateTime range?"}
	Domain -->|No| ArithError(["Uncaught ArgumentOutOfRangeException<br>from DateTime.AddSeconds"])
	Domain -->|Yes| MinVal{"nowBufferSeconds<br>== int.MinValue?"}
	MinVal -->|Yes, wraps to int.MinValue| MinValWindow{"shiftedNow = now.AddSeconds(int.MinValue);<br>shiftedNow >= createDate && shiftedNow < expires?"}
	MinVal -->|No| Window{"createDate within<br>(now − abs(buffer) − expireSeconds, now + abs(buffer)]?"}
	Window -->|No| Expired([Throw SystemHashExpiredException])
	Window -->|Yes| ReturnHash(["Return unsalted hash — caller must compare separately"])
	MinValWindow -->|Yes| ReturnHash
	MinValWindow -->|No| Expired
```

## Failure and edge-case behavior
- A malformed credential fails with a BCL exception type, not `SystemHashExpiredException`, because de-salting failures are not caught ([BR-SH-09](../business-rules.md#br-sh-09-malformed-credential-raises-bcl-exceptions)).
- The window check's two `OR` clauses are symmetric in the sign of `nowBufferSeconds` (the literal source evaluates the `nowBufferSeconds * -1` arm first in each bound check and short-circuits, evaluating the plain `nowBufferSeconds` arm only when that first arm is false), so a negative `nowBufferSeconds` produces the same window as a positive buffer of equal magnitude — it does not narrow the window ([BR-SH-08](../business-rules.md#br-sh-08-credential-lifetime-window)). This symmetry holds for every `nowBufferSeconds` value except `int.MinValue`. Because the arithmetic is unchecked (no `CheckForOverflowUnderflow` project setting and no `checked` block), `int.MinValue * -1` wraps silently to `int.MinValue` instead of throwing `OverflowException`. Both arms then shift `now` back by about 68 years, so the window moves into the past — but this value is not special-cased to an unconditional outcome, it is still evaluated through the same shifted-window predicate as any other `nowBufferSeconds`. A current credential's `createDate` still fails that shifted predicate and is rejected with `SystemHashExpiredException`, but a credential whose `createDate` is itself roughly 68+ years in the past, combined with a sufficiently large `expireSeconds`, can satisfy the shifted predicate and be **accepted** instead ([BR-SH-08](../business-rules.md#br-sh-08-credential-lifetime-window)). Separately, any `AddSeconds` shift that falls outside the `DateTime` range throws `ArgumentOutOfRangeException`. There is no guard against a zero or negative `expireSeconds`; such a value shrinks or eliminates the acceptance window rather than causing the check to always pass or always fail based on sign alone.*
- Returning a hash from this operation does not by itself authorize the caller — the comparison step is the validating service's own responsibility ([BR-SH-10](../business-rules.md#br-sh-10-lifetime-validation-is-not-identity)).

## Related documentation
- [Operations and processes](../operations-processes.md)
- [Business rules](../business-rules.md)

> **Note:** Statements marked with `*` were inferred from code structure, naming
> conventions, or contextual evidence where direct confirmation was not available
> in source artifacts.

## Sources
- `src/SAIF.NetCore.SystemHash/SAIF.NetCore.SystemHash/SAIF.NetCore.SystemHash/SystemHashService.cs`
- `src/SAIF.NetCore.HashUtil/SAIF.NetCore.HashUtil/SaltShaker.cs`, `SystemHashExpiredException.cs`
