Release v0.2.0 - Security and integrity features

- Add REST API response signing using HMAC-SHA256
- Add SHA256 hash validation for version file uploads
- Add ResponseSigner class for automatic API response signing
- Add file_hash column to database schema
- Remove external URL support from version uploads
- Update translations with all fuzzy strings resolved

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-22 16:57:54 +01:00
parent 8420734f37
commit 23bbc24c5f
14 changed files with 789 additions and 75 deletions

View File

@@ -23,6 +23,7 @@ class ProductVersion
private ?string $releaseNotes;
private ?string $downloadUrl;
private ?int $attachmentId;
private ?string $fileHash;
private bool $isActive;
private \DateTimeInterface $releasedAt;
private \DateTimeInterface $createdAt;
@@ -42,6 +43,7 @@ class ProductVersion
$version->releaseNotes = $data['release_notes'] ?: null;
$version->downloadUrl = $data['download_url'] ?: null;
$version->attachmentId = !empty($data['attachment_id']) ? (int) $data['attachment_id'] : null;
$version->fileHash = $data['file_hash'] ?? null;
$version->isActive = (bool) $data['is_active'];
$version->releasedAt = new \DateTimeImmutable($data['released_at']);
$version->createdAt = new \DateTimeImmutable($data['created_at']);
@@ -137,15 +139,20 @@ class ProductVersion
return $this->attachmentId;
}
public function getFileHash(): ?string
{
return $this->fileHash;
}
/**
* Get the effective download URL (from attachment or direct URL)
* Get the download URL from attachment
*/
public function getEffectiveDownloadUrl(): ?string
{
if ($this->attachmentId) {
return wp_get_attachment_url($this->attachmentId) ?: null;
}
return $this->downloadUrl;
return null;
}
/**
@@ -156,9 +163,6 @@ class ProductVersion
if ($this->attachmentId) {
return wp_basename(get_attached_file($this->attachmentId) ?: '');
}
if ($this->downloadUrl) {
return wp_basename($this->downloadUrl);
}
return null;
}
@@ -192,6 +196,7 @@ class ProductVersion
'release_notes' => $this->releaseNotes,
'download_url' => $this->downloadUrl,
'attachment_id' => $this->attachmentId,
'file_hash' => $this->fileHash,
'is_active' => $this->isActive,
'released_at' => $this->releasedAt->format('Y-m-d H:i:s'),
'created_at' => $this->createdAt->format('Y-m-d H:i:s'),

View File

@@ -92,16 +92,27 @@ class VersionManager
/**
* Create a new version
*
* @throws \InvalidArgumentException If file hash validation fails
*/
public function createVersion(
int $productId,
string $version,
?string $releaseNotes = null,
?string $downloadUrl = null,
?int $attachmentId = null
?int $attachmentId = null,
?string $fileHash = null
): ?ProductVersion {
global $wpdb;
// Validate file hash if both attachment and hash are provided
if ($attachmentId !== null && $attachmentId > 0 && $fileHash !== null && $fileHash !== '') {
$validatedHash = $this->validateFileHash($attachmentId, $fileHash);
if ($validatedHash === false) {
return null;
}
$fileHash = $validatedHash;
}
$parsed = ProductVersion::parseVersion($version);
$tableName = Installer::getVersionsTable();
@@ -114,10 +125,9 @@ class VersionManager
'minor_version' => $parsed['minor'],
'patch_version' => $parsed['patch'],
'release_notes' => $releaseNotes,
'download_url' => $downloadUrl,
'is_active' => 1,
];
$formats = ['%d', '%s', '%d', '%d', '%d', '%s', '%s', '%d'];
$formats = ['%d', '%s', '%d', '%d', '%d', '%s', '%d'];
// Only include attachment_id if it's set
if ($attachmentId !== null && $attachmentId > 0) {
@@ -125,6 +135,12 @@ class VersionManager
$formats[] = '%d';
}
// Only include file_hash if it's set
if ($fileHash !== null && $fileHash !== '') {
$data['file_hash'] = $fileHash;
$formats[] = '%s';
}
$result = $wpdb->insert($tableName, $data, $formats);
if ($result === false) {
@@ -136,13 +152,44 @@ class VersionManager
return $this->getVersionById((int) $wpdb->insert_id);
}
/**
* Validate file hash against attachment
*
* @return string|false The validated hash (lowercase) or false on mismatch
* @throws \InvalidArgumentException If hash doesn't match
*/
private function validateFileHash(int $attachmentId, string $providedHash): string|false
{
$filePath = get_attached_file($attachmentId);
if (!$filePath || !file_exists($filePath)) {
throw new \InvalidArgumentException(
__('Attachment file not found.', 'wc-licensed-product')
);
}
$calculatedHash = hash_file('sha256', $filePath);
$providedHash = strtolower(trim($providedHash));
if (!hash_equals($calculatedHash, $providedHash)) {
throw new \InvalidArgumentException(
sprintf(
/* translators: 1: provided hash, 2: calculated hash */
__('File checksum does not match. Expected: %1$s, Got: %2$s', 'wc-licensed-product'),
$providedHash,
$calculatedHash
)
);
}
return $calculatedHash;
}
/**
* Update a version
*/
public function updateVersion(
int $versionId,
?string $releaseNotes = null,
?string $downloadUrl = null,
?bool $isActive = null,
?int $attachmentId = null
): bool {
@@ -156,11 +203,6 @@ class VersionManager
$formats[] = '%s';
}
if ($downloadUrl !== null) {
$data['download_url'] = $downloadUrl;
$formats[] = '%s';
}
if ($isActive !== null) {
$data['is_active'] = $isActive ? 1 : 0;
$formats[] = '%d';