A symmetric crypto layer that uses AES-256-CBC + HMAC-SHA256 (Encrypt-then-MAC) with a time-windowed IV (OTP-like) and derives keys via HKDF-SHA256.
This library does not create HTTP requests; it operates only on header/body payloads. Dart (client) and PHP (server) implement the same protocol.
Summary
- The IV is not transmitted. Both sides derive it as
iv = HMAC_SHA256(macKey, "iv" || u64be(window) || nonce)[:16]- Encrypt-then-MAC: encrypt with
AES-256-CBC, then MAC withHMAC-SHA256.- Keys are derived by HKDF-SHA256:
enc_key(32B) +mac_key(32B).- Time window:
window = floor(epochSeconds / 30)(default 30s).- Wire format:
- Headers:
{ "v":1, "w":<int>, "n":"<b64_nonce>", "c":"<b64_ciphertext>" }- Body:
"<b64_tag>"
pubspec.yaml is included. Example dependencies:
dependencies:
crypto: ^3.0.3
encrypt: ^5.0.1
meta: ^1.11.0
dev_dependencies:
test: ^1.25.0
dio: ^5.4.0
lints: ^3.0.0Note: The library itself does not perform HTTP. The example app (if you choose to send requests) uses
dio.
lib/
├─ otp_crypto/
│ ├─ otp_crypto_config.dart # Singleton config (v, masterKey, salt/info, windows)
│ ├─ utils.dart # b64, u64be, constant-time compare, helpers
│ ├─ errors.dart # safe error messages & exception types
│ ├─ sha256_hmac.dart # HMAC-SHA256 (pure Dart; SHA-256 digest via crypto)
│ ├─ hkdf.dart # HKDF-SHA256 (extract+expand)
│ ├─ time_provider.dart # SystemTimeProvider / AdjustableTimeProvider
│ ├─ rand_nonce.dart # 8-byte nonce generator (CSPRNG)
│ ├─ iv_deriver.dart # IV = HMAC(macKey,"iv"||u64be(w)||nonce)[:16]
│ ├─ otp_cipher.dart # AES-256-CBC + PKCS#7 (uses `encrypt`)
│ ├─ tag_deriver.dart # tag = HMAC(macKey,"tag"||u64be(w)||n||c)
│ ├─ encryptor.dart # high-level encryption (produces SecureMessage)
│ └─ decryptor.dart # verify + decrypt
├─ http/
│ └─ api_client.dart # header/body adapters (no HTTP)
└─ models/
└─ secure_message.dart # wire model (headers/body)
-
Version (
v):1 -
Time window (
w):floor(epochSeconds / 30)(default30) -
Nonce (
n): 8 bytes, CSPRNG -
HKDF-SHA256:
- PRK = HMAC(salt, masterKey)
- OKM (64B) = expand(PRK, info, 64) →
encKey(first 32B) +macKey(next 32B)
-
IV derivation:
HMAC_SHA256(macKey, "iv" || u64be(w) || nonce)[:16] -
Encryption: AES-256-CBC + PKCS#7 (
encKey,iv) -
MAC (EtM):
HMAC_SHA256(macKey, "tag" || u64be(w) || nonce || ciphertext) -
Wire format:
-
Headers:
{ "v": 1, "w": <int>, "n": "<b64_nonce>", "c": "<b64_ciphertext>" } -
Body:
"<b64_tag>"
-
Do not send IV. Each side computes it with the same algorithm.
import 'dart:typed_data';
import 'package:otp_crypto/otp_crypto/otp_crypto_config.dart';
import 'package:otp_crypto/otp_crypto/time_provider.dart';
void main() {
// At least 32 bytes (example only; store securely in production):
final masterKey = Uint8List.fromList(List<int>.generate(32, (i) => i));
OtpCryptoConfig.initialize(
masterKey: masterKey,
salt: null, // optional; recommended: protocol constant
info: null, // optional; recommended: "otp-v1"
protocolVersion: 1,
windowSeconds: 30,
verificationSkewWindows: 0, // acceptable ±N windows
timeProvider: SystemTimeProvider(),
);
}import 'dart:typed_data';
import 'package:otp_crypto/otp_crypto/encryptor.dart';
import 'package:otp_crypto/models/secure_message.dart';
final enc = Encryptor();
final plaintext = Uint8List.fromList('Hello secure world'.codeUnits);
final SecureMessage msg = enc.protect(plaintext);
// `msg` is ready to be serialized into headers/bodyimport 'dart:typed_data';
import 'package:otp_crypto/otp_crypto/decryptor.dart';
import 'package:otp_crypto/models/secure_message.dart';
final dec = Decryptor();
final Uint8List plain = dec.unprotect(msg);import 'package:otp_crypto/http/api_client.dart';
// Sender side:
final wire = ApiClient.toWire(msg, extraHeaders: {'X-App-Id': 'myapp'});
// wire.headers -> {"v","w","n","c",...}, wire.body -> "<b64_tag>"
// Receiver side:
final parsed = ApiClient.parseWire(headers: wire.headers, body: wire.body);
// parsed -> SecureMessage; then call Decryptor.unprotect(parsed)The library does not perform HTTP; the following is application-level.
import 'package:dio/dio.dart';
import 'package:otp_crypto/http/api_client.dart';
import 'package:otp_crypto/otp_crypto/encryptor.dart';
import 'package:otp_crypto/otp_crypto/decryptor.dart';
final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
final enc = Encryptor();
final dec = Decryptor();
// 1) Build encrypted request
final msg = enc.protect(Uint8List.fromList('{"q":"ping"}'.codeUnits));
final wire = ApiClient.toWire(msg, extraHeaders: {'X-App-Id': 'demo'});
// 2) Send (headers/body)
final resp = await dio.post(
'/secure-endpoint',
options: Options(headers: wire.headers),
data: wire.body, // String (Base64 tag)
);
// 3) Parse and decrypt the response
final replyMsg = ApiClient.parseWire(
headers: Map<String, String>.from(resp.headers.map.map(
(k, v) => MapEntry(k, v.join(',')),
)),
body: resp.data is String ? resp.data as String : resp.data.toString(),
);
final plain = dec.unprotect(replyMsg);
print(String.fromCharCodes(plain));Generic, non-leaking messages:
Invalid messageAuthentication failedDecryption failedExpired or not yet validInternal error
See exception classes under lib/otp_crypto/errors.dart.
- EtM: never decrypt before MAC verification.
- No IV transmission: IV is derived from
macKey; protectmacKeycarefully. - Replay: within the 30s window, track seen nonces (e.g., LRU/cache) at higher layers.
- Clock sync: adjust
verificationSkewWindowsif you must accept ± windows (e.g.,1→[w-1, w, w+1]). - Key management:
masterKey≥ 32B; secure distribution/storage is mandatory. - Error hygiene: do not leak cryptographic internals.
- Constant-time compare: use
constantTimeEqualsfor tag checks.
- Interop: messages produced in Dart should decrypt in PHP, and vice versa.
- Wrong key: MAC/decryption must fail.
- Wrong window: reject when outside tolerance.
- Nonce length: reject if not exactly 8 bytes.
- Malformed Base64: reject.
- Large payloads: test padding and performance.
For full end-to-end samples, see the
example/folder (to be added in this repo).
A PHP implementation of a time-windowed IV (OTP-like) symmetric encryption layer using AES-256-CBC + HMAC-SHA256 (Encrypt-then-MAC) with keys derived by HKDF-SHA256.
This library does not create HTTP requests/responses; it only operates on header/body content so you can plug it into any framework.
It interoperates with the Dart client library using the exact same protocol.
- PHP 8.1+
- Extensions:
ext-openssl,ext-hash - Composer (for autoloading and dev tools)
composer installIf you plan to depend on this from another project (monorepo), add a path repository or publish it to your VCS/Packagist accordingly.
Headers:
{
"v": 1, // protocol version
"w": <int>, // time window = floor(epochSeconds / 30)
"n": "<b64_nonce>", // 8 random bytes (Base64)
"c": "<b64_ciphertext>" // AES-256-CBC ciphertext (Base64)
}Body:
"<b64_tag>" // HMAC-SHA256(tag input) in Base64
Key details:
-
HKDF-SHA256 derives two 32B keys:
encKey(AES) andmacKey(HMAC). -
IV is not transmitted; both sides derive it:
iv = HMAC_SHA256(macKey, "iv" || u64be(window) || nonce)[:16] -
Tag (Encrypt-then-MAC):
tag = HMAC_SHA256(macKey, "tag" || u64be(window) || nonce || ciphertext)
Default window size is 30 seconds (configurable).
src/
├─ Crypto/
│ ├─ OtpCryptoConfig.php # Singleton config (v, masterKey, salt/info, windows)
│ ├─ Utils.php # Base64, u64be, constant-time compare
│ ├─ HmacSha256.php # HMAC-SHA256 helpers (binary)
│ ├─ Hkdf.php # HKDF-SHA256 (extract+expand), DerivedKeys VO
│ ├─ IvDeriver.php # iv = HMAC(macKey, "iv"||u64be(w)||nonce)[:16]
│ ├─ TagDeriver.php # tag = HMAC(macKey, "tag"||u64be(w)||nonce||c)
│ ├─ OtpCipher.php # AES-256-CBC + PKCS#7 (OpenSSL)
│ ├─ RandNonce.php # 8-byte CSPRNG nonce
│ └─ Errors.php # Exceptions & safe messages
├─ Http/
│ ├─ Encryptor.php # Build SecureMessage (no HTTP)
│ └─ Decryptor.php # Verify & decrypt SecureMessage (no HTTP)
├─ Models/
│ └─ SecureMessage.php # Wire model (headers/body parsing/serialization)
└─ public/
└─ index.php # Demo endpoint
use OtpCrypto\Crypto\OtpCryptoConfig;
$masterKey = base64_decode(getenv('OTP_MASTER_KEY_B64') ?: '', true);
if ($masterKey === false || strlen($masterKey) < 32) {
// DEV ONLY fallback; use a secure secret in production!
$masterKey = random_bytes(32);
}
OtpCryptoConfig::init([
'masterKey' => $masterKey, // binary string (≥32B)
'salt' => null, // optional binary; recommend protocol constant
'info' => "otp-v1", // optional binary; binds keys to this protocol
'protocolVersion' => 1,
'windowSeconds' => 30,
'verificationSkewWindows' => 0, // set 1 to accept [w-1, w, w+1]
]);use OtpCrypto\Models\SecureMessage;
use OtpCrypto\Http\Decryptor;
$wireHeaders = [
'v' => $_SERVER['HTTP_V'] ?? null,
'w' => $_SERVER['HTTP_W'] ?? null,
'n' => $_SERVER['HTTP_N'] ?? null,
'c' => $_SERVER['HTTP_C'] ?? null,
];
$wireBody = file_get_contents('php://input') ?: '';
$msg = SecureMessage::fromWire($wireHeaders, $wireBody);
$dec = new Decryptor();
$plaintext = $dec->unprotect($msg);
// $plaintext is a binary string (e.g., JSON)use OtpCrypto\Http\Encryptor;
$enc = new Encryptor();
$respMsg = $enc->protect(json_encode(['ok' => true]) ?: '{}');
$respHeaders = $respMsg->toWireHeaders();
$respBody = $respMsg->toWireBody();
// write headers v/w/n/c and body (Base64 tag) via your frameworkSee
src/public/index.phpfor a complete demo that echoes back the request.
All errors return generic, non-leaking messages (mirrors the Dart side):
Invalid messageAuthentication failedDecryption failedExpired or not yet validInternal error
Use the exception classes in src/Crypto/Errors.php for precise handling while keeping responses generic.
- Encrypt-then-MAC: always verify the tag before decryption.
- No IV on the wire: IV is derived from
macKey; protectmacKeycarefully. - Replay: consider tracking seen nonces per time-window at the application layer.
- Clock sync: use NTP; set
verificationSkewWindowsif you must accept adjacent windows. - Key management:
masterKeymust be ≥32B; store/distribute securely. - Constant-time compare: use
Utils::constantTimeEqualsfor tags. - Error hygiene: never leak crypto internals in outward messages/logs.
Run PHPUnit tests (to be added):
composer testSuggested scenarios:
- Interop with Dart vectors (same master key & clock).
- Wrong key → authentication/decryption fails.
- Wrong window → rejected when outside tolerance.
- Malformed Base64 / bad lengths →
Invalid message. - Large payloads → correct padding and performance.
For a quick demo:
php -S 127.0.0.1:8080 -t src/publicSend a request from your Dart client using the wire headers/body as specified. The demo will parse, decrypt, and respond with an encrypted reply in the same wire format.