mirror of
https://github.com/nextcloud/server.git
synced 2026-05-28 04:32:30 -04:00
fix: Make sodium optional
This commit switches the default signature algorithm to ecdsa-p256-sha256 instead of Ed25519. This allows us to make sodium optional again, and we only pull it in to use it for verifying incomming signatures. If sodium is not installed, we throw on Ed25519 signatures instead. At least it is easy for most people to make their Nextcloud install fully RFC compliant by installing sodium. I also renamed all the Ed25519 function names to be more precis, using Jwks for the JSON Web Keys, and RFC9421 for the http-signature code, where it is needed to distinguish from draft-cavage signatures. Signed-off-by: Micke Nordin <kano@sunet.se>
This commit is contained in:
parent
1b4c9b21d2
commit
1bad4fe238
19 changed files with 382 additions and 224 deletions
|
|
@ -24,7 +24,6 @@ class PhpModules implements ISetupCheck {
|
|||
'openssl',
|
||||
'posix',
|
||||
'session',
|
||||
'sodium',
|
||||
'xml',
|
||||
'xmlreader',
|
||||
'xmlwriter',
|
||||
|
|
@ -36,6 +35,7 @@ class PhpModules implements ISetupCheck {
|
|||
'exif',
|
||||
'gmp',
|
||||
'intl',
|
||||
'sodium',
|
||||
'sysvsem',
|
||||
];
|
||||
|
||||
|
|
@ -58,6 +58,7 @@ class PhpModules implements ISetupCheck {
|
|||
protected function getRecommendedModuleDescription(string $module): string {
|
||||
return match($module) {
|
||||
'intl' => $this->l10n->t('increases language translation performance and fixes sorting of non-ASCII characters'),
|
||||
'sodium' => $this->l10n->t('for Argon2 for password hashing and Ed25519 signature verification for RFC 9421 http message signatures'),
|
||||
'gmp' => $this->l10n->t('required for SFTP storage and recommended for WebAuthn performance'),
|
||||
'exif' => $this->l10n->t('for picture rotation in server and metadata extraction in the Photos app'),
|
||||
default => '',
|
||||
|
|
|
|||
|
|
@ -41,13 +41,15 @@
|
|||
"ext-posix": "*",
|
||||
"ext-session": "*",
|
||||
"ext-simplexml": "*",
|
||||
"ext-sodium": "*",
|
||||
"ext-xml": "*",
|
||||
"ext-xmlreader": "*",
|
||||
"ext-xmlwriter": "*",
|
||||
"ext-zip": "*",
|
||||
"ext-zlib": "*"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-sodium": "Argon2 password hashing and Ed25519 signature verification for RFC 9421 HTTP message signatures."
|
||||
},
|
||||
"require-dev": {
|
||||
"bamarni/composer-bin-plugin": "^1.4"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -24,13 +24,13 @@ class ActivateKey extends Base {
|
|||
protected function configure(): void {
|
||||
$this
|
||||
->setName('ocm:keys:activate')
|
||||
->setDescription('promote the staged Ed25519 key to active; the previous active key moves to retiring');
|
||||
->setDescription('promote the staged JWKS key to active; the previous active key moves to retiring');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
try {
|
||||
$this->signatoryManager->activateStagedEd25519Key();
|
||||
$this->signatoryManager->activateStagedJwksKey();
|
||||
} catch (\RuntimeException $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return self::FAILURE;
|
||||
|
|
|
|||
|
|
@ -25,13 +25,13 @@ class ListKeys extends Base {
|
|||
protected function configure(): void {
|
||||
$this
|
||||
->setName('ocm:keys:list')
|
||||
->setDescription('list Ed25519 keys used by OCM RFC 9421 HTTP Message Signatures');
|
||||
->setDescription('list JWKS-published signing keys');
|
||||
parent::configure();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$keys = $this->signatoryManager->listEd25519Keys();
|
||||
$keys = $this->signatoryManager->listJwksKeys();
|
||||
$format = $input->getOption('output');
|
||||
if ($format === self::OUTPUT_FORMAT_JSON || $format === self::OUTPUT_FORMAT_JSON_PRETTY) {
|
||||
$output->writeln(json_encode($keys, $format === self::OUTPUT_FORMAT_JSON_PRETTY ? JSON_PRETTY_PRINT : 0));
|
||||
|
|
@ -39,7 +39,7 @@ class ListKeys extends Base {
|
|||
}
|
||||
|
||||
if ($keys === []) {
|
||||
$output->writeln('<comment>No Ed25519 keys yet; one will be generated on first OCM request.</comment>');
|
||||
$output->writeln('<comment>No JWKS keys yet; one will be generated on first OCM request.</comment>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,13 +24,13 @@ class RetireKey extends Base {
|
|||
protected function configure(): void {
|
||||
$this
|
||||
->setName('ocm:keys:retire')
|
||||
->setDescription('delete the retiring Ed25519 key; signatures that referenced its kid can no longer be verified');
|
||||
->setDescription('delete the retiring JWKS key; signatures that referenced its kid can no longer be verified');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
try {
|
||||
$this->signatoryManager->retireEd25519Key();
|
||||
$this->signatoryManager->retireJwksKey();
|
||||
} catch (\RuntimeException $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return self::FAILURE;
|
||||
|
|
|
|||
|
|
@ -24,18 +24,18 @@ class StageKey extends Base {
|
|||
protected function configure(): void {
|
||||
$this
|
||||
->setName('ocm:keys:stage')
|
||||
->setDescription('generate a new Ed25519 key and advertise it via JWKS without using it for signing yet');
|
||||
->setDescription('generate a new JWKS key and advertise it via JWKS without using it for signing yet');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
try {
|
||||
$signatory = $this->signatoryManager->stageEd25519Key();
|
||||
$signatory = $this->signatoryManager->stageJwksKey();
|
||||
} catch (\RuntimeException $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
$output->writeln('Staged new Ed25519 key: <info>' . $signatory->getKeyId() . '</info>');
|
||||
$output->writeln('Staged new JWKS key: <info>' . $signatory->getKeyId() . '</info>');
|
||||
$output->writeln('Wait for federated peers to refresh their JWKS cache before activating.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ use OCP\IAppConfig;
|
|||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
|
||||
/** Serves `/.well-known/jwks.json` (RFC 7517) for the RFC 9421 keys. */
|
||||
/** Serves `/.well-known/jwks.json` (RFC 7517) with the OCM signing keys. */
|
||||
class OCMJwksHandler implements IHandler {
|
||||
public function __construct(
|
||||
private readonly IAppConfig $appConfig,
|
||||
|
|
@ -36,11 +36,11 @@ class OCMJwksHandler implements IHandler {
|
|||
$keys = [];
|
||||
if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
|
||||
try {
|
||||
foreach ($this->signatoryManager->getLocalEd25519Jwks() as $jwk) {
|
||||
foreach ($this->signatoryManager->getLocalJwks() as $jwk) {
|
||||
$keys[] = $jwk;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->warning('failed to build local Ed25519 JWKs', ['exception' => $e]);
|
||||
$this->logger->warning('failed to build local JWKs', ['exception' => $e]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,18 +50,18 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager {
|
|||
public const APPCONFIG_SIGN_ENFORCED = 'ocm_signed_request_enforced';
|
||||
private const APPKEY_CAVAGE = 'ocm_external';
|
||||
private const KEYID_FRAGMENT_CAVAGE = 'signature';
|
||||
private const KEYID_FRAGMENT_ED25519 = 'ed25519';
|
||||
/** Ed25519 keypairs live in numbered pool appkeys; slots point to them by id. */
|
||||
private const APPKEY_ED25519_POOL_PREFIX = 'ocm_ed25519_pool_';
|
||||
private const APPCONFIG_ED25519_POOL_COUNTER = 'ocm_ed25519_pool_counter';
|
||||
private const APPCONFIG_ED25519_POOL_KID_PREFIX = 'ocm_ed25519_pool_kid_';
|
||||
private const KEYID_FRAGMENT_JWKS = 'ecdsa-p256-sha256';
|
||||
/** JWKS-published keypairs live in numbered pool appkeys; slots point to them by id. */
|
||||
private const APPKEY_JWKS_POOL_PREFIX = 'ocm_jwks_pool_';
|
||||
private const APPCONFIG_JWKS_POOL_COUNTER = 'ocm_jwks_pool_counter';
|
||||
private const APPCONFIG_JWKS_POOL_KID_PREFIX = 'ocm_jwks_pool_kid_';
|
||||
/** Stable kid identity portion, reused across rotations so kids stay on one hostname. */
|
||||
private const APPCONFIG_ED25519_KID_BASE = 'ocm_ed25519_kid_base';
|
||||
private const APPCONFIG_JWKS_KID_BASE = 'ocm_jwks_kid_base';
|
||||
public const SLOT_ACTIVE = 'active';
|
||||
public const SLOT_PENDING = 'pending';
|
||||
public const SLOT_RETIRING = 'retiring';
|
||||
/** All slots in advertise order. */
|
||||
public const ED25519_SLOTS = [self::SLOT_ACTIVE, self::SLOT_PENDING, self::SLOT_RETIRING];
|
||||
public const JWKS_SLOTS = [self::SLOT_ACTIVE, self::SLOT_PENDING, self::SLOT_RETIRING];
|
||||
/** Remote JWKS cache TTL (seconds). */
|
||||
private const JWKS_CACHE_TTL = 3600;
|
||||
|
||||
|
|
@ -142,11 +142,11 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager {
|
|||
|
||||
}
|
||||
|
||||
/** Active Ed25519 signing key, lazily provisioned. */
|
||||
public function getLocalEd25519Signatory(): ?Signatory {
|
||||
/** Active JWKS-published signing key (ECDSA P-256), lazily provisioned. */
|
||||
public function getLocalJwksSignatory(): ?Signatory {
|
||||
$poolId = $this->getSlotPool(self::SLOT_ACTIVE);
|
||||
if ($poolId === null) {
|
||||
$poolId = $this->generatePool($this->nextEd25519PoolKid());
|
||||
$poolId = $this->generatePool($this->nextPoolKid());
|
||||
$this->setSlotPool(self::SLOT_ACTIVE, $poolId);
|
||||
}
|
||||
return $this->signatoryFromPool($poolId);
|
||||
|
|
@ -158,61 +158,61 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager {
|
|||
*
|
||||
* @return list<array<string, string>>
|
||||
*/
|
||||
public function getLocalEd25519Jwks(): array {
|
||||
public function getLocalJwks(): array {
|
||||
if ($this->getSlotPool(self::SLOT_ACTIVE) === null) {
|
||||
$this->getLocalEd25519Signatory();
|
||||
$this->getLocalJwksSignatory();
|
||||
}
|
||||
|
||||
$jwks = [];
|
||||
foreach (self::ED25519_SLOTS as $slot) {
|
||||
foreach (self::JWKS_SLOTS as $slot) {
|
||||
$poolId = $this->getSlotPool($slot);
|
||||
if ($poolId === null) {
|
||||
continue;
|
||||
}
|
||||
$signatory = $this->signatoryFromPool($poolId);
|
||||
if ($signatory !== null) {
|
||||
$jwks[] = self::buildEd25519JwkArray($signatory->getPublicKey(), $signatory->getKeyId());
|
||||
$jwks[] = self::buildEcdsaP256JwkArray($signatory->getPublicKey(), $signatory->getKeyId());
|
||||
}
|
||||
}
|
||||
return $jwks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a pending Ed25519 keypair (advertised in JWKS, not yet used
|
||||
* for outbound signing).
|
||||
* Generate a pending keypair (advertised in JWKS, not yet used for
|
||||
* outbound signing).
|
||||
*
|
||||
* @throws \RuntimeException if pending is already populated
|
||||
*/
|
||||
public function stageEd25519Key(): Signatory {
|
||||
public function stageJwksKey(): Signatory {
|
||||
if ($this->getSlotPool(self::SLOT_PENDING) !== null) {
|
||||
throw new \RuntimeException('a pending Ed25519 key already exists; activate or retire it first');
|
||||
throw new \RuntimeException('a pending JWKS key already exists; activate or retire it first');
|
||||
}
|
||||
// Need an active key first; staging a next from nothing makes no sense.
|
||||
if ($this->getSlotPool(self::SLOT_ACTIVE) === null) {
|
||||
$this->getLocalEd25519Signatory();
|
||||
$this->getLocalJwksSignatory();
|
||||
}
|
||||
$poolId = $this->generatePool($this->nextEd25519PoolKid());
|
||||
$poolId = $this->generatePool($this->nextPoolKid());
|
||||
$this->setSlotPool(self::SLOT_PENDING, $poolId);
|
||||
$signatory = $this->signatoryFromPool($poolId);
|
||||
if ($signatory === null) {
|
||||
throw new \RuntimeException('failed to materialise newly staged Ed25519 key');
|
||||
throw new \RuntimeException('failed to materialise newly staged JWKS key');
|
||||
}
|
||||
return $signatory;
|
||||
}
|
||||
|
||||
/**
|
||||
* pending -> active, previous active -> retiring. The retiring slot
|
||||
* stays in JWKS until {@see retireEd25519Key} is run.
|
||||
* stays in JWKS until {@see retireJwksKey} is run.
|
||||
*
|
||||
* @throws \RuntimeException if no pending key is staged, or retiring is occupied
|
||||
*/
|
||||
public function activateStagedEd25519Key(): void {
|
||||
public function activateStagedJwksKey(): void {
|
||||
$pending = $this->getSlotPool(self::SLOT_PENDING);
|
||||
if ($pending === null) {
|
||||
throw new \RuntimeException('no pending Ed25519 key to activate; run `ocm:keys:stage` first');
|
||||
throw new \RuntimeException('no pending JWKS key to activate; run `ocm:keys:stage` first');
|
||||
}
|
||||
if ($this->getSlotPool(self::SLOT_RETIRING) !== null) {
|
||||
throw new \RuntimeException('a retiring Ed25519 key still exists; retire it before activating a new one');
|
||||
throw new \RuntimeException('a retiring JWKS key still exists; retire it before activating a new one');
|
||||
}
|
||||
$active = $this->getSlotPool(self::SLOT_ACTIVE);
|
||||
|
||||
|
|
@ -229,13 +229,13 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager {
|
|||
*
|
||||
* @throws \RuntimeException if retiring is empty
|
||||
*/
|
||||
public function retireEd25519Key(): void {
|
||||
public function retireJwksKey(): void {
|
||||
$poolId = $this->getSlotPool(self::SLOT_RETIRING);
|
||||
if ($poolId === null) {
|
||||
throw new \RuntimeException('no retiring Ed25519 key to remove');
|
||||
throw new \RuntimeException('no retiring JWKS key to remove');
|
||||
}
|
||||
$this->identityProofManager->deleteAppKey('core', self::APPKEY_ED25519_POOL_PREFIX . $poolId);
|
||||
$this->appConfig->deleteKey('core', self::APPCONFIG_ED25519_POOL_KID_PREFIX . $poolId);
|
||||
$this->identityProofManager->deleteAppKey('core', self::APPKEY_JWKS_POOL_PREFIX . $poolId);
|
||||
$this->appConfig->deleteKey('core', self::APPCONFIG_JWKS_POOL_KID_PREFIX . $poolId);
|
||||
$this->clearSlot(self::SLOT_RETIRING);
|
||||
}
|
||||
|
||||
|
|
@ -244,25 +244,25 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager {
|
|||
*
|
||||
* @return list<array{poolId: int, kid: string, slot: ?string}>
|
||||
*/
|
||||
public function listEd25519Keys(): array {
|
||||
public function listJwksKeys(): array {
|
||||
$bySlot = [];
|
||||
foreach (self::ED25519_SLOTS as $slot) {
|
||||
foreach (self::JWKS_SLOTS as $slot) {
|
||||
$id = $this->getSlotPool($slot);
|
||||
if ($id !== null) {
|
||||
$bySlot[$id] = $slot;
|
||||
}
|
||||
}
|
||||
|
||||
$max = $this->appConfig->getValueInt('core', self::APPCONFIG_ED25519_POOL_COUNTER, 0);
|
||||
$max = $this->appConfig->getValueInt('core', self::APPCONFIG_JWKS_POOL_COUNTER, 0);
|
||||
$entries = [];
|
||||
for ($id = 1; $id <= $max; $id++) {
|
||||
if (!$this->identityProofManager->hasAppKey('core', self::APPKEY_ED25519_POOL_PREFIX . $id)) {
|
||||
if (!$this->identityProofManager->hasAppKey('core', self::APPKEY_JWKS_POOL_PREFIX . $id)) {
|
||||
continue;
|
||||
}
|
||||
$entries[] = [
|
||||
'poolId' => $id,
|
||||
'kid' => $this->canonicalKid(
|
||||
$this->appConfig->getValueString('core', self::APPCONFIG_ED25519_POOL_KID_PREFIX . $id, ''),
|
||||
$this->appConfig->getValueString('core', self::APPCONFIG_JWKS_POOL_KID_PREFIX . $id, ''),
|
||||
),
|
||||
'slot' => $bySlot[$id] ?? null,
|
||||
];
|
||||
|
|
@ -275,11 +275,11 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager {
|
|||
* {@see Signatory::setKeyId} so admin output and wire form agree.
|
||||
*/
|
||||
private function generatePool(string $kid): int {
|
||||
$poolId = $this->appConfig->getValueInt('core', self::APPCONFIG_ED25519_POOL_COUNTER, 0) + 1;
|
||||
$this->appConfig->setValueInt('core', self::APPCONFIG_ED25519_POOL_COUNTER, $poolId);
|
||||
$poolId = $this->appConfig->getValueInt('core', self::APPCONFIG_JWKS_POOL_COUNTER, 0) + 1;
|
||||
$this->appConfig->setValueInt('core', self::APPCONFIG_JWKS_POOL_COUNTER, $poolId);
|
||||
|
||||
$this->identityProofManager->generateEd25519AppKey('core', self::APPKEY_ED25519_POOL_PREFIX . $poolId);
|
||||
$this->appConfig->setValueString('core', self::APPCONFIG_ED25519_POOL_KID_PREFIX . $poolId, $this->canonicalKid($kid));
|
||||
$this->identityProofManager->generateEcdsaP256AppKey('core', self::APPKEY_JWKS_POOL_PREFIX . $poolId);
|
||||
$this->appConfig->setValueString('core', self::APPCONFIG_JWKS_POOL_KID_PREFIX . $poolId, $this->canonicalKid($kid));
|
||||
return $poolId;
|
||||
}
|
||||
|
||||
|
|
@ -296,22 +296,22 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager {
|
|||
*
|
||||
* @throws \RuntimeException if no instance identity can be derived
|
||||
*/
|
||||
private function nextEd25519PoolKid(): string {
|
||||
$base = $this->resolveEd25519KidBase();
|
||||
$next = $this->appConfig->getValueInt('core', self::APPCONFIG_ED25519_POOL_COUNTER, 0) + 1;
|
||||
private function nextPoolKid(): string {
|
||||
$base = $this->resolveKidBase();
|
||||
$next = $this->appConfig->getValueInt('core', self::APPCONFIG_JWKS_POOL_COUNTER, 0) + 1;
|
||||
return $base . '-' . $next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable identity portion (before the `-N` suffix). Resolution order:
|
||||
* stored APPCONFIG_ED25519_KID_BASE > active pool's kid sans suffix >
|
||||
* stored APPCONFIG_JWKS_KID_BASE > active pool's kid sans suffix >
|
||||
* fresh from {@see buildLocalKeyId}. Persisted so CLI rotations stay
|
||||
* on one hostname.
|
||||
*
|
||||
* @throws \RuntimeException if no instance identity can be derived
|
||||
*/
|
||||
private function resolveEd25519KidBase(): string {
|
||||
$base = $this->appConfig->getValueString('core', self::APPCONFIG_ED25519_KID_BASE, '');
|
||||
private function resolveKidBase(): string {
|
||||
$base = $this->appConfig->getValueString('core', self::APPCONFIG_JWKS_KID_BASE, '');
|
||||
if ($base !== '') {
|
||||
return $base;
|
||||
}
|
||||
|
|
@ -319,7 +319,7 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager {
|
|||
$activePool = $this->getSlotPool(self::SLOT_ACTIVE);
|
||||
if ($activePool !== null) {
|
||||
$kid = $this->canonicalKid(
|
||||
$this->appConfig->getValueString('core', self::APPCONFIG_ED25519_POOL_KID_PREFIX . $activePool, ''),
|
||||
$this->appConfig->getValueString('core', self::APPCONFIG_JWKS_POOL_KID_PREFIX . $activePool, ''),
|
||||
);
|
||||
$pos = strrpos($kid, '-');
|
||||
if ($pos !== false) {
|
||||
|
|
@ -329,18 +329,18 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager {
|
|||
|
||||
if ($base === '') {
|
||||
try {
|
||||
$base = $this->canonicalKid($this->buildLocalKeyId(self::KEYID_FRAGMENT_ED25519));
|
||||
$base = $this->canonicalKid($this->buildLocalKeyId(self::KEYID_FRAGMENT_JWKS));
|
||||
} catch (IdentityNotFoundException $e) {
|
||||
throw new \RuntimeException('cannot derive instance identity for Ed25519 kid', 0, $e);
|
||||
throw new \RuntimeException('cannot derive instance identity for JWKS kid', 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
$this->appConfig->setValueString('core', self::APPCONFIG_ED25519_KID_BASE, $base);
|
||||
$this->appConfig->setValueString('core', self::APPCONFIG_JWKS_KID_BASE, $base);
|
||||
return $base;
|
||||
}
|
||||
|
||||
private function getSlotPool(string $slot): ?int {
|
||||
$key = 'ocm_ed25519_slot_' . $slot;
|
||||
$key = 'ocm_jwks_slot_' . $slot;
|
||||
if (!$this->appConfig->hasKey('core', $key)) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -349,20 +349,20 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager {
|
|||
}
|
||||
|
||||
private function setSlotPool(string $slot, int $poolId): void {
|
||||
$this->appConfig->setValueInt('core', 'ocm_ed25519_slot_' . $slot, $poolId);
|
||||
$this->appConfig->setValueInt('core', 'ocm_jwks_slot_' . $slot, $poolId);
|
||||
}
|
||||
|
||||
private function clearSlot(string $slot): void {
|
||||
$this->appConfig->deleteKey('core', 'ocm_ed25519_slot_' . $slot);
|
||||
$this->appConfig->deleteKey('core', 'ocm_jwks_slot_' . $slot);
|
||||
}
|
||||
|
||||
/** Returns null if the underlying appkey was manually deleted. */
|
||||
private function signatoryFromPool(int $poolId): ?Signatory {
|
||||
$appKey = self::APPKEY_ED25519_POOL_PREFIX . $poolId;
|
||||
$appKey = self::APPKEY_JWKS_POOL_PREFIX . $poolId;
|
||||
if (!$this->identityProofManager->hasAppKey('core', $appKey)) {
|
||||
return null;
|
||||
}
|
||||
$kid = $this->appConfig->getValueString('core', self::APPCONFIG_ED25519_POOL_KID_PREFIX . $poolId, '');
|
||||
$kid = $this->appConfig->getValueString('core', self::APPCONFIG_JWKS_POOL_KID_PREFIX . $poolId, '');
|
||||
if ($kid === '') {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -375,7 +375,7 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param string $fragment URL fragment (e.g. 'signature', 'ed25519')
|
||||
* @param string $fragment URL fragment (e.g. 'signature' for cavage, 'ecdsa-p256-sha256' for the JWKS-published key)
|
||||
* @return string
|
||||
* @throws IdentityNotFoundException
|
||||
*/
|
||||
|
|
@ -531,16 +531,27 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Build an EC P-256 JWK (RFC 7518 §6.2) from a PEM public key. The raw x/y
|
||||
* coordinates from openssl are zero-padded to 32 bytes per RFC 7518 §6.2.1.2.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function buildEd25519JwkArray(string $rawPublicKey, string $kid): array {
|
||||
private static function buildEcdsaP256JwkArray(string $publicKeyPem, string $kid): array {
|
||||
$details = openssl_pkey_get_details(openssl_pkey_get_public($publicKeyPem) ?: throw new \RuntimeException('invalid EC public key'));
|
||||
if ($details === false || !isset($details['ec']['x'], $details['ec']['y'])) {
|
||||
throw new \RuntimeException('invalid EC public key');
|
||||
}
|
||||
$x = str_pad($details['ec']['x'], 32, "\x00", STR_PAD_LEFT);
|
||||
$y = str_pad($details['ec']['y'], 32, "\x00", STR_PAD_LEFT);
|
||||
|
||||
return [
|
||||
'kty' => 'OKP',
|
||||
'crv' => 'Ed25519',
|
||||
'kty' => 'EC',
|
||||
'crv' => 'P-256',
|
||||
'kid' => $kid,
|
||||
'alg' => 'EdDSA',
|
||||
'alg' => 'ES256',
|
||||
'use' => 'sig',
|
||||
'x' => JWT::urlsafeB64Encode($rawPublicKey),
|
||||
'x' => JWT::urlsafeB64Encode($x),
|
||||
'y' => JWT::urlsafeB64Encode($y),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ use OCP\Security\Signature\Model\Signatory;
|
|||
|
||||
/**
|
||||
* Per-call wrapper around {@see OCMSignatoryManager} that swaps in the
|
||||
* Ed25519 signatory and sets `rfc9421.format`. Wrapping (vs mutating) keeps
|
||||
* the underlying DI-managed instance stateless across requests.
|
||||
* JWKS-published signatory and sets `rfc9421.format`. Wrapping (vs mutating)
|
||||
* keeps the underlying DI-managed instance stateless across requests.
|
||||
*/
|
||||
final class Rfc9421SignatoryManager implements IJwkResolvingSignatoryManager {
|
||||
public function __construct(
|
||||
|
|
@ -37,9 +37,9 @@ final class Rfc9421SignatoryManager implements IJwkResolvingSignatoryManager {
|
|||
|
||||
#[\Override]
|
||||
public function getLocalSignatory(): Signatory {
|
||||
$signatory = $this->delegate->getLocalEd25519Signatory();
|
||||
$signatory = $this->delegate->getLocalJwksSignatory();
|
||||
if ($signatory === null) {
|
||||
throw new IdentityNotFoundException('no Ed25519 signatory available');
|
||||
throw new IdentityNotFoundException('no JWKS-published signatory available');
|
||||
}
|
||||
return $signatory;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -179,14 +179,31 @@ class Manager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Generate an Ed25519 keypair via libsodium. Returns raw 32-byte public
|
||||
* + 64-byte secret (sodium seed||publickey), no PEM. Overwrites if
|
||||
* already present.
|
||||
* Generate an ECDSA P-256 (prime256v1, SECG/JOSE ES256 curve) keypair via
|
||||
* openssl. Returns PEM private + PEM public. Overwrites if already
|
||||
* present. Private key is encrypted on disk.
|
||||
*
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
public function generateEd25519AppKey(string $app, string $name): Key {
|
||||
$keyPair = sodium_crypto_sign_keypair();
|
||||
$publicKey = sodium_crypto_sign_publickey($keyPair);
|
||||
$privateKey = sodium_crypto_sign_secretkey($keyPair);
|
||||
public function generateEcdsaP256AppKey(string $app, string $name): Key {
|
||||
$res = openssl_pkey_new([
|
||||
'private_key_type' => OPENSSL_KEYTYPE_EC,
|
||||
'curve_name' => 'prime256v1',
|
||||
]);
|
||||
if ($res === false) {
|
||||
$this->logOpensslError();
|
||||
throw new \RuntimeException('OpenSSL reported a problem');
|
||||
}
|
||||
if (openssl_pkey_export($res, $privateKey) === false) {
|
||||
$this->logOpensslError();
|
||||
throw new \RuntimeException('OpenSSL reported a problem');
|
||||
}
|
||||
$details = openssl_pkey_get_details($res);
|
||||
if ($details === false || !isset($details['key'])) {
|
||||
$this->logOpensslError();
|
||||
throw new \RuntimeException('OpenSSL reported a problem');
|
||||
}
|
||||
$publicKey = $details['key'];
|
||||
|
||||
$id = $this->generateAppKeyId($app, $name);
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -23,8 +23,9 @@ use OCP\Security\Signature\ISignatoryManager;
|
|||
|
||||
/**
|
||||
* RFC 9421 implementation of {@see IOutgoingSignedRequest}, sibling to the
|
||||
* draft-cavage {@see OutgoingSignedRequest}. Default Ed25519 with the `alg`
|
||||
* parameter omitted (RFC 9421 §3.3.7); verifier resolves it from the JWK.
|
||||
* draft-cavage {@see OutgoingSignedRequest}. Default ECDSA P-256 (`ES256`)
|
||||
* with the `alg` parameter omitted (RFC 9421 §3.3.7); verifier resolves it
|
||||
* from the JWK.
|
||||
*
|
||||
* Options from {@see ISignatoryManager::getOptions()}: `rfc9421.signingAlgorithm`,
|
||||
* `rfc9421.coveredComponents`, `rfc9421.contentDigestAlgorithm`,
|
||||
|
|
@ -60,7 +61,7 @@ class Rfc9421OutgoingSignedRequest extends SignedRequest implements
|
|||
->setSignatory($signatoryManager->getLocalSignatory())
|
||||
->setDigestAlgorithm($options['digestAlgorithm'] ?? DigestAlgorithm::SHA256);
|
||||
|
||||
$this->signingAlgorithm = (string)($options['rfc9421.signingAlgorithm'] ?? 'ed25519');
|
||||
$this->signingAlgorithm = (string)($options['rfc9421.signingAlgorithm'] ?? 'ecdsa-p256-sha256');
|
||||
$contentDigestAlgorithm = (string)($options['rfc9421.contentDigestAlgorithm'] ?? ContentDigest::ALGO_SHA256);
|
||||
/** @var list<string> $components */
|
||||
$components = $options['rfc9421.coveredComponents'] ?? self::DEFAULT_COMPONENTS;
|
||||
|
|
@ -138,7 +139,7 @@ class Rfc9421OutgoingSignedRequest extends SignedRequest implements
|
|||
return $this->algorithm;
|
||||
}
|
||||
|
||||
/** RFC 9421 alg name (e.g. `ed25519`). Distinct from cavage's {@see getAlgorithm()}. */
|
||||
/** RFC 9421 alg name (e.g. `ecdsa-p256-sha256`). Distinct from cavage's {@see getAlgorithm()}. */
|
||||
public function getSigningAlgorithm(): string {
|
||||
return $this->signingAlgorithm;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,16 @@ use Throwable;
|
|||
/**
|
||||
* RFC 9421 §3.3 sign/verify primitives.
|
||||
*
|
||||
* Asymmetric algorithms only: RSA-PKCS1-v1_5 (SHA-256/384/512), ECDSA P-256
|
||||
* SHA-256, ECDSA P-384 SHA-384, Ed25519. JOSE aliases (RFC 7518 / RFC 8037)
|
||||
* Sign supports asymmetric algorithms reachable via ext-openssl: RSA-PKCS1-v1_5
|
||||
* (SHA-256/384/512) and ECDSA P-256 / P-384. JOSE aliases (RFC 7518 / RFC 8037)
|
||||
* accepted per RFC 9421 §3.3.7. RSA-PSS is rejected: OPENSSL_PKCS1_PSS_PADDING
|
||||
* needs PHP 8.5 and we still support 8.2-8.4.
|
||||
*
|
||||
* Verify additionally accepts Ed25519 when ext-sodium is loaded; without sodium
|
||||
* an Ed25519 signature throws {@see SignatureException}. Sodium is used directly
|
||||
* because firebase/php-jwt's `validateEdDSAKey` base64url-decodes the key
|
||||
* material, which mangles the raw sodium bytes.
|
||||
*
|
||||
* Sign delegates to {@see JWT::sign}. Verify takes a {@see Key} parsed by
|
||||
* firebase/php-jwt (which has already validated the JWK's kty/crv/alg
|
||||
* consistency) and only enforces the cross-source agreement between the JWK
|
||||
|
|
@ -39,12 +44,8 @@ final class Algorithm {
|
|||
];
|
||||
|
||||
/**
|
||||
* For Ed25519 $privateKey is the raw 64-byte sodium secret key; otherwise
|
||||
* a PEM private key. Returns raw signature bytes (R||S for ECDSA).
|
||||
*
|
||||
* Ed25519 calls sodium directly: JWT::sign runs the key through
|
||||
* `validateEdDSAKey` which base64url-decodes it first, which mangles raw
|
||||
* sodium bytes.
|
||||
* $privateKey is a PEM private key. Returns raw signature bytes (R||S for
|
||||
* ECDSA). Ed25519 is verify-only and is rejected here.
|
||||
*
|
||||
* @throws SignatureException
|
||||
*/
|
||||
|
|
@ -52,10 +53,7 @@ final class Algorithm {
|
|||
$normalized = self::normalize($algorithm);
|
||||
|
||||
if ($normalized === 'ed25519') {
|
||||
if (strlen($privateKey) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) {
|
||||
throw new SignatureException('Ed25519 secret key must be ' . SODIUM_CRYPTO_SIGN_SECRETKEYBYTES . ' bytes');
|
||||
}
|
||||
return sodium_crypto_sign_detached($signatureBase, $privateKey);
|
||||
throw new SignatureException('Ed25519 signing is not supported; use ECDSA P-256 or RSA');
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -85,6 +83,9 @@ final class Algorithm {
|
|||
$material = $key->getKeyMaterial();
|
||||
|
||||
if ($resolved === 'ed25519') {
|
||||
if (!function_exists('sodium_crypto_sign_verify_detached')) {
|
||||
throw new SignatureException('verifying Ed25519 signatures requires ext-sodium');
|
||||
}
|
||||
if (strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -154,7 +155,6 @@ final class Algorithm {
|
|||
|
||||
private static function nativeToJose(string $native): string {
|
||||
return match ($native) {
|
||||
'ed25519' => 'EdDSA',
|
||||
'ecdsa-p256-sha256' => 'ES256',
|
||||
'ecdsa-p384-sha384' => 'ES384',
|
||||
'rsa-v1_5-sha256' => 'RS256',
|
||||
|
|
|
|||
|
|
@ -54,23 +54,24 @@ class OCMJwksHandlerTest extends TestCase {
|
|||
$this->appConfig->method('getValueBool')
|
||||
->with('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, false, true)
|
||||
->willReturn(true);
|
||||
$this->signatoryManager->expects($this->never())->method('getLocalEd25519Jwks');
|
||||
$this->signatoryManager->expects($this->never())->method('getLocalJwks');
|
||||
|
||||
$body = $this->jsonBody($this->handler->handle('jwks.json', $this->context, null));
|
||||
$this->assertSame(['keys' => []], $body);
|
||||
}
|
||||
|
||||
public function testPublishesEd25519JwksWhenAvailable(): void {
|
||||
public function testPublishesJwksWhenAvailable(): void {
|
||||
$this->appConfig->method('getValueBool')->willReturn(false);
|
||||
$jwk = [
|
||||
'kty' => 'OKP',
|
||||
'crv' => 'Ed25519',
|
||||
'kid' => 'https://example.org/ocm#ed25519',
|
||||
'alg' => 'EdDSA',
|
||||
'kty' => 'EC',
|
||||
'crv' => 'P-256',
|
||||
'kid' => 'https://example.org/ocm#ecdsa-p256-sha256',
|
||||
'alg' => 'ES256',
|
||||
'use' => 'sig',
|
||||
'x' => 'AAAA',
|
||||
'y' => 'BBBB',
|
||||
];
|
||||
$this->signatoryManager->method('getLocalEd25519Jwks')->willReturn([$jwk]);
|
||||
$this->signatoryManager->method('getLocalJwks')->willReturn([$jwk]);
|
||||
|
||||
$body = $this->jsonBody($this->handler->handle('jwks.json', $this->context, null));
|
||||
$this->assertSame(['keys' => [$jwk]], $body);
|
||||
|
|
@ -79,12 +80,12 @@ class OCMJwksHandlerTest extends TestCase {
|
|||
public function testPublishesAllSlotsAdvertisedDuringRotation(): void {
|
||||
$this->appConfig->method('getValueBool')->willReturn(false);
|
||||
$active = [
|
||||
'kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'kid-1', 'alg' => 'EdDSA', 'use' => 'sig', 'x' => 'AAAA',
|
||||
'kty' => 'EC', 'crv' => 'P-256', 'kid' => 'kid-1', 'alg' => 'ES256', 'use' => 'sig', 'x' => 'AAAA', 'y' => 'BBBB',
|
||||
];
|
||||
$pending = [
|
||||
'kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'kid-2', 'alg' => 'EdDSA', 'use' => 'sig', 'x' => 'BBBB',
|
||||
'kty' => 'EC', 'crv' => 'P-256', 'kid' => 'kid-2', 'alg' => 'ES256', 'use' => 'sig', 'x' => 'CCCC', 'y' => 'DDDD',
|
||||
];
|
||||
$this->signatoryManager->method('getLocalEd25519Jwks')->willReturn([$active, $pending]);
|
||||
$this->signatoryManager->method('getLocalJwks')->willReturn([$active, $pending]);
|
||||
|
||||
$body = $this->jsonBody($this->handler->handle('jwks.json', $this->context, null));
|
||||
$this->assertSame(['keys' => [$active, $pending]], $body);
|
||||
|
|
@ -92,7 +93,7 @@ class OCMJwksHandlerTest extends TestCase {
|
|||
|
||||
public function testEmptyKeySetWhenSignatoryUnavailable(): void {
|
||||
$this->appConfig->method('getValueBool')->willReturn(false);
|
||||
$this->signatoryManager->method('getLocalEd25519Jwks')->willReturn([]);
|
||||
$this->signatoryManager->method('getLocalJwks')->willReturn([]);
|
||||
|
||||
$body = $this->jsonBody($this->handler->handle('jwks.json', $this->context, null));
|
||||
$this->assertSame(['keys' => []], $body);
|
||||
|
|
@ -100,7 +101,7 @@ class OCMJwksHandlerTest extends TestCase {
|
|||
|
||||
public function testFailingJwkBuildIsLoggedAndYieldsEmptyKeySet(): void {
|
||||
$this->appConfig->method('getValueBool')->willReturn(false);
|
||||
$this->signatoryManager->method('getLocalEd25519Jwks')
|
||||
$this->signatoryManager->method('getLocalJwks')
|
||||
->willThrowException(new \RuntimeException('boom'));
|
||||
$this->logger->expects($this->once())->method('warning');
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ use Psr\Log\LoggerInterface;
|
|||
use Test\TestCase;
|
||||
|
||||
class OCMSignatoryManagerJwksTest extends TestCase {
|
||||
/** RFC 7517 §A.1 test vector for an EC P-256 public key. */
|
||||
private const TEST_X = 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU';
|
||||
private const TEST_Y = 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0';
|
||||
|
||||
private IAppConfig&MockObject $appConfig;
|
||||
private ISignatureManager&MockObject $signatureManager;
|
||||
private IURLGenerator&MockObject $urlGenerator;
|
||||
|
|
@ -68,21 +72,19 @@ class OCMSignatoryManagerJwksTest extends TestCase {
|
|||
$kid = 'sender.example.org#key1';
|
||||
$jwks = [
|
||||
'keys' => [
|
||||
['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'other', 'x' => 'AAAA'],
|
||||
['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => $kid, 'x' => 'BBBB'],
|
||||
$this->ecJwk('other'),
|
||||
$this->ecJwk($kid),
|
||||
],
|
||||
];
|
||||
$this->respondWith($jwks);
|
||||
|
||||
$key = $this->signatoryManager->getRemoteKey('sender.example.org', $kid);
|
||||
$this->assertNotNull($key);
|
||||
$this->assertSame('EdDSA', $key->getAlgorithm());
|
||||
// Key stores OKP material as plain base64 of the raw bytes.
|
||||
$this->assertSame('BBBB', $key->getKeyMaterial());
|
||||
$this->assertSame('ES256', $key->getAlgorithm());
|
||||
}
|
||||
|
||||
public function testGetRemoteKeyReturnsNullWhenKidMissing(): void {
|
||||
$this->respondWith(['keys' => [['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'unrelated', 'x' => 'AAAA']]]);
|
||||
$this->respondWith(['keys' => [$this->ecJwk('unrelated')]]);
|
||||
$this->assertNull($this->signatoryManager->getRemoteKey('sender.example.org', 'other-kid'));
|
||||
}
|
||||
|
||||
|
|
@ -106,8 +108,8 @@ class OCMSignatoryManagerJwksTest extends TestCase {
|
|||
}
|
||||
|
||||
public function testGetRemoteKeyReturnsNullOnUnparseableJwk(): void {
|
||||
// JWK with kty=OKP but no crv: parseKey rejects.
|
||||
$this->respondWith(['keys' => [['kty' => 'OKP', 'kid' => 'kid', 'x' => 'AAAA']]]);
|
||||
// JWK with kty=EC but no crv: parseKey rejects.
|
||||
$this->respondWith(['keys' => [['kty' => 'EC', 'kid' => 'kid', 'x' => self::TEST_X, 'y' => self::TEST_Y]]]);
|
||||
$this->logger->expects($this->once())->method('warning');
|
||||
$this->assertNull($this->signatoryManager->getRemoteKey('sender.example.org', 'kid'));
|
||||
}
|
||||
|
|
@ -142,7 +144,7 @@ class OCMSignatoryManagerJwksTest extends TestCase {
|
|||
|
||||
public function testJwksCachedAcrossCallsToTheSameOrigin(): void {
|
||||
$kid = 'sender.example.org#key1';
|
||||
$jwks = ['keys' => [['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => $kid, 'x' => 'AAAA']]];
|
||||
$jwks = ['keys' => [$this->ecJwk($kid)]];
|
||||
$this->client->expects($this->once())
|
||||
->method('get')
|
||||
->willReturn($this->jsonResponse($jwks));
|
||||
|
|
@ -152,8 +154,8 @@ class OCMSignatoryManagerJwksTest extends TestCase {
|
|||
}
|
||||
|
||||
public function testCacheMissOnNewKidTriggersRefetchOnce(): void {
|
||||
$first = ['keys' => [['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'old', 'x' => 'AAAA']]];
|
||||
$second = ['keys' => [['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'new', 'x' => 'BBBB']]];
|
||||
$first = ['keys' => [$this->ecJwk('old')]];
|
||||
$second = ['keys' => [$this->ecJwk('new')]];
|
||||
$this->client->expects($this->exactly(2))
|
||||
->method('get')
|
||||
->willReturnOnConsecutiveCalls(
|
||||
|
|
@ -174,4 +176,17 @@ class OCMSignatoryManagerJwksTest extends TestCase {
|
|||
$response->method('getBody')->willReturn(json_encode($body, JSON_THROW_ON_ERROR));
|
||||
return $response;
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
private function ecJwk(string $kid): array {
|
||||
return [
|
||||
'kty' => 'EC',
|
||||
'crv' => 'P-256',
|
||||
'kid' => $kid,
|
||||
'alg' => 'ES256',
|
||||
'use' => 'sig',
|
||||
'x' => self::TEST_X,
|
||||
'y' => self::TEST_Y,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ use PHPUnit\Framework\MockObject\MockObject;
|
|||
use Psr\Log\LoggerInterface;
|
||||
use Test\TestCase;
|
||||
|
||||
/** Ed25519 stage / activate / retire lifecycle, with stateful IAppConfig + IdentityProofManager fakes. */
|
||||
/** JWKS stage / activate / retire lifecycle, with stateful IAppConfig + IdentityProofManager fakes. */
|
||||
class OCMSignatoryManagerRotationTest extends TestCase {
|
||||
private IAppConfig&MockObject $appConfig;
|
||||
private IdentityProofManager&MockObject $identityProofManager;
|
||||
|
|
@ -66,118 +66,118 @@ class OCMSignatoryManagerRotationTest extends TestCase {
|
|||
|
||||
public function testJwksBootstrapsActiveKeyOnFirstFetch(): void {
|
||||
// Fresh instance: first JWKS hit must provision the active key.
|
||||
$jwks = $this->signatoryManager->getLocalEd25519Jwks();
|
||||
$jwks = $this->signatoryManager->getLocalJwks();
|
||||
$this->assertCount(1, $jwks);
|
||||
$this->assertSame('https://alice.example/ocm#ed25519-1', $jwks[0]['kid']);
|
||||
$this->assertSame('https://alice.example/ocm#ecdsa-p256-sha256-1', $jwks[0]['kid']);
|
||||
|
||||
// And the bootstrapped key is the active one for outbound signing.
|
||||
$signatory = $this->signatoryManager->getLocalEd25519Signatory();
|
||||
$signatory = $this->signatoryManager->getLocalJwksSignatory();
|
||||
$this->assertSame($jwks[0]['kid'], $signatory->getKeyId());
|
||||
}
|
||||
|
||||
public function testFirstCallProvisionsActiveKey(): void {
|
||||
$signatory = $this->signatoryManager->getLocalEd25519Signatory();
|
||||
$signatory = $this->signatoryManager->getLocalJwksSignatory();
|
||||
$this->assertNotNull($signatory);
|
||||
$this->assertSame('https://alice.example/ocm#ed25519-1', $signatory->getKeyId());
|
||||
$this->assertSame('https://alice.example/ocm#ecdsa-p256-sha256-1', $signatory->getKeyId());
|
||||
|
||||
$jwks = $this->signatoryManager->getLocalEd25519Jwks();
|
||||
$jwks = $this->signatoryManager->getLocalJwks();
|
||||
$this->assertCount(1, $jwks);
|
||||
$this->assertSame($signatory->getKeyId(), $jwks[0]['kid']);
|
||||
|
||||
$listed = $this->signatoryManager->listEd25519Keys();
|
||||
$listed = $this->signatoryManager->listJwksKeys();
|
||||
$this->assertSame([['poolId' => 1, 'kid' => $signatory->getKeyId(), 'slot' => 'active']], $listed);
|
||||
}
|
||||
|
||||
public function testStageDoesNotChangeActiveSignerButPublishesNewJwk(): void {
|
||||
$initial = $this->signatoryManager->getLocalEd25519Signatory();
|
||||
$staged = $this->signatoryManager->stageEd25519Key();
|
||||
$initial = $this->signatoryManager->getLocalJwksSignatory();
|
||||
$staged = $this->signatoryManager->stageJwksKey();
|
||||
$this->assertNotSame($initial->getKeyId(), $staged->getKeyId());
|
||||
|
||||
// Active signer is unchanged.
|
||||
$this->assertSame($initial->getKeyId(), $this->signatoryManager->getLocalEd25519Signatory()->getKeyId());
|
||||
$this->assertSame($initial->getKeyId(), $this->signatoryManager->getLocalJwksSignatory()->getKeyId());
|
||||
|
||||
// JWKS now advertises both kids, active first then pending.
|
||||
$jwks = $this->signatoryManager->getLocalEd25519Jwks();
|
||||
$jwks = $this->signatoryManager->getLocalJwks();
|
||||
$this->assertSame([$initial->getKeyId(), $staged->getKeyId()], array_column($jwks, 'kid'));
|
||||
}
|
||||
|
||||
public function testStageRefusesIfPendingAlreadyExists(): void {
|
||||
$this->signatoryManager->stageEd25519Key();
|
||||
$this->signatoryManager->stageJwksKey();
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/pending Ed25519 key already exists/');
|
||||
$this->signatoryManager->stageEd25519Key();
|
||||
$this->expectExceptionMessageMatches('/pending JWKS key already exists/');
|
||||
$this->signatoryManager->stageJwksKey();
|
||||
}
|
||||
|
||||
public function testActivatePromotesPendingAndDemotesActive(): void {
|
||||
$first = $this->signatoryManager->getLocalEd25519Signatory();
|
||||
$staged = $this->signatoryManager->stageEd25519Key();
|
||||
$this->signatoryManager->activateStagedEd25519Key();
|
||||
$first = $this->signatoryManager->getLocalJwksSignatory();
|
||||
$staged = $this->signatoryManager->stageJwksKey();
|
||||
$this->signatoryManager->activateStagedJwksKey();
|
||||
|
||||
// New signer is the formerly-staged key.
|
||||
$this->assertSame($staged->getKeyId(), $this->signatoryManager->getLocalEd25519Signatory()->getKeyId());
|
||||
$this->assertSame($staged->getKeyId(), $this->signatoryManager->getLocalJwksSignatory()->getKeyId());
|
||||
|
||||
// JWKS still advertises the former active key as retiring so peers
|
||||
// verifying in-flight signatures with its kid don't fail.
|
||||
$kids = array_column($this->signatoryManager->getLocalEd25519Jwks(), 'kid');
|
||||
$kids = array_column($this->signatoryManager->getLocalJwks(), 'kid');
|
||||
$this->assertContains($first->getKeyId(), $kids);
|
||||
$this->assertContains($staged->getKeyId(), $kids);
|
||||
}
|
||||
|
||||
public function testActivateRefusesIfRetiringStillPopulated(): void {
|
||||
$this->signatoryManager->getLocalEd25519Signatory();
|
||||
$this->signatoryManager->stageEd25519Key();
|
||||
$this->signatoryManager->activateStagedEd25519Key();
|
||||
$this->signatoryManager->getLocalJwksSignatory();
|
||||
$this->signatoryManager->stageJwksKey();
|
||||
$this->signatoryManager->activateStagedJwksKey();
|
||||
// Retiring slot is now populated; staging again is allowed but
|
||||
// activating must refuse until the admin explicitly retires the old
|
||||
// key.
|
||||
$this->signatoryManager->stageEd25519Key();
|
||||
$this->signatoryManager->stageJwksKey();
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/retiring Ed25519 key still exists/');
|
||||
$this->signatoryManager->activateStagedEd25519Key();
|
||||
$this->expectExceptionMessageMatches('/retiring JWKS key still exists/');
|
||||
$this->signatoryManager->activateStagedJwksKey();
|
||||
}
|
||||
|
||||
public function testActivateRefusesWithoutPendingKey(): void {
|
||||
$this->signatoryManager->getLocalEd25519Signatory();
|
||||
$this->signatoryManager->getLocalJwksSignatory();
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/no pending Ed25519 key/');
|
||||
$this->signatoryManager->activateStagedEd25519Key();
|
||||
$this->expectExceptionMessageMatches('/no pending JWKS key/');
|
||||
$this->signatoryManager->activateStagedJwksKey();
|
||||
}
|
||||
|
||||
public function testRetireRemovesRetiringKeyFromJwks(): void {
|
||||
$first = $this->signatoryManager->getLocalEd25519Signatory();
|
||||
$staged = $this->signatoryManager->stageEd25519Key();
|
||||
$this->signatoryManager->activateStagedEd25519Key();
|
||||
$this->signatoryManager->retireEd25519Key();
|
||||
$first = $this->signatoryManager->getLocalJwksSignatory();
|
||||
$staged = $this->signatoryManager->stageJwksKey();
|
||||
$this->signatoryManager->activateStagedJwksKey();
|
||||
$this->signatoryManager->retireJwksKey();
|
||||
|
||||
$kids = array_column($this->signatoryManager->getLocalEd25519Jwks(), 'kid');
|
||||
$kids = array_column($this->signatoryManager->getLocalJwks(), 'kid');
|
||||
$this->assertSame([$staged->getKeyId()], $kids);
|
||||
// listEd25519Keys also drops the retired pool.
|
||||
$listed = $this->signatoryManager->listEd25519Keys();
|
||||
// listJwksKeys also drops the retired pool.
|
||||
$listed = $this->signatoryManager->listJwksKeys();
|
||||
$this->assertCount(1, $listed);
|
||||
$this->assertSame($staged->getKeyId(), $listed[0]['kid']);
|
||||
$this->assertNotContains($first->getKeyId(), array_column($listed, 'kid'));
|
||||
}
|
||||
|
||||
public function testRetireRefusesWhenNothingToRetire(): void {
|
||||
$this->signatoryManager->getLocalEd25519Signatory();
|
||||
$this->signatoryManager->getLocalJwksSignatory();
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/no retiring Ed25519 key/');
|
||||
$this->signatoryManager->retireEd25519Key();
|
||||
$this->expectExceptionMessageMatches('/no retiring JWKS key/');
|
||||
$this->signatoryManager->retireJwksKey();
|
||||
}
|
||||
|
||||
public function testKidStaysStableThroughLifecycle(): void {
|
||||
$first = $this->signatoryManager->getLocalEd25519Signatory();
|
||||
$staged = $this->signatoryManager->stageEd25519Key();
|
||||
$first = $this->signatoryManager->getLocalJwksSignatory();
|
||||
$staged = $this->signatoryManager->stageJwksKey();
|
||||
// kid for the staged key must stay the same once it is activated;
|
||||
// peers that cached it during the stage window must still resolve it.
|
||||
$this->signatoryManager->activateStagedEd25519Key();
|
||||
$this->assertSame($staged->getKeyId(), $this->signatoryManager->getLocalEd25519Signatory()->getKeyId());
|
||||
$this->signatoryManager->activateStagedJwksKey();
|
||||
$this->assertSame($staged->getKeyId(), $this->signatoryManager->getLocalJwksSignatory()->getKeyId());
|
||||
|
||||
$this->signatoryManager->retireEd25519Key();
|
||||
$this->signatoryManager->stageEd25519Key();
|
||||
$this->signatoryManager->retireJwksKey();
|
||||
$this->signatoryManager->stageJwksKey();
|
||||
// And every newly minted kid must differ from prior ones, no pool
|
||||
// counter rewinding.
|
||||
$kids = array_column($this->signatoryManager->listEd25519Keys(), 'kid');
|
||||
$kids = array_column($this->signatoryManager->listJwksKeys(), 'kid');
|
||||
$this->assertNotContains($first->getKeyId(), $kids);
|
||||
$this->assertSame($kids, array_unique($kids));
|
||||
}
|
||||
|
|
@ -208,7 +208,7 @@ class OCMSignatoryManagerRotationTest extends TestCase {
|
|||
);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$manager->getLocalEd25519Signatory();
|
||||
$manager->getLocalJwksSignatory();
|
||||
}
|
||||
|
||||
private function wireAppConfig(): void {
|
||||
|
|
@ -245,10 +245,15 @@ class OCMSignatoryManagerRotationTest extends TestCase {
|
|||
$this->identityProofManager->method('hasAppKey')->willReturnCallback(
|
||||
fn (string $app, string $name): bool => isset($this->appKeyStore[$app . '/' . $name])
|
||||
);
|
||||
$this->identityProofManager->method('generateEd25519AppKey')->willReturnCallback(
|
||||
$this->identityProofManager->method('generateEcdsaP256AppKey')->willReturnCallback(
|
||||
function (string $app, string $name): Key {
|
||||
$keyPair = sodium_crypto_sign_keypair();
|
||||
$key = new Key(sodium_crypto_sign_publickey($keyPair), sodium_crypto_sign_secretkey($keyPair));
|
||||
$res = openssl_pkey_new([
|
||||
'private_key_type' => OPENSSL_KEYTYPE_EC,
|
||||
'curve_name' => 'prime256v1',
|
||||
]);
|
||||
openssl_pkey_export($res, $privatePem);
|
||||
$publicPem = openssl_pkey_get_details($res)['key'];
|
||||
$key = new Key($publicPem, $privatePem);
|
||||
$this->appKeyStore[$app . '/' . $name] = $key;
|
||||
return $key;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,15 +39,15 @@ class Rfc9421SignatoryManagerTest extends TestCase {
|
|||
$this->assertSame('rsa-sha512', $options['algorithm']);
|
||||
}
|
||||
|
||||
public function testGetLocalSignatoryReturnsEd25519Key(): void {
|
||||
public function testGetLocalSignatoryReturnsJwksKey(): void {
|
||||
$signatory = $this->createMock(Signatory::class);
|
||||
$this->delegate->method('getLocalEd25519Signatory')->willReturn($signatory);
|
||||
$this->delegate->method('getLocalJwksSignatory')->willReturn($signatory);
|
||||
|
||||
$this->assertSame($signatory, $this->wrapper->getLocalSignatory());
|
||||
}
|
||||
|
||||
public function testGetLocalSignatoryThrowsWhenEd25519Unavailable(): void {
|
||||
$this->delegate->method('getLocalEd25519Signatory')->willReturn(null);
|
||||
public function testGetLocalSignatoryThrowsWhenJwksKeyUnavailable(): void {
|
||||
$this->delegate->method('getLocalJwksSignatory')->willReturn(null);
|
||||
|
||||
$this->expectException(IdentityNotFoundException::class);
|
||||
$this->wrapper->getLocalSignatory();
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ use OCP\Security\Signature\Model\Signatory;
|
|||
use Test\TestCase;
|
||||
|
||||
class Rfc9421RoundTripTest extends TestCase {
|
||||
public function testEd25519RoundTripVerifies(): void {
|
||||
[$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
|
||||
public function testEcdsaP256RoundTripVerifies(): void {
|
||||
[$signatory, $jwk] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
|
||||
$signatoryManager = $this->makeSignatoryManager($signatory);
|
||||
|
||||
$body = '{"hello":"world"}';
|
||||
|
|
@ -43,8 +43,30 @@ class Rfc9421RoundTripTest extends TestCase {
|
|||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function testEd25519VerifyAcceptedWhenSodiumLoaded(): void {
|
||||
$this->skipUnlessSodium();
|
||||
[$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
|
||||
$signatoryManager = $this->makeSignatoryManagerWithSigningAlgorithm($signatory, 'ed25519');
|
||||
|
||||
$body = '{"hello":"world"}';
|
||||
$out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
|
||||
// Ed25519 sign() throws via Algorithm::sign; produce the signature directly.
|
||||
$rawSig = sodium_crypto_sign_detached($out->getSignatureBaseString(), $signatory->getPrivateKey());
|
||||
$out->setSignature(base64_encode($rawSig));
|
||||
$headers = $out->getHeaders();
|
||||
$paramsLine = '("@method" "@target-uri" "content-digest" "content-length" "date");created=' . time() . ';keyid="' . $signatory->getKeyId() . '"';
|
||||
$headers['Signature-Input'] = 'ocm=' . $paramsLine;
|
||||
$headers['Signature'] = 'ocm=:' . base64_encode($rawSig) . ':';
|
||||
|
||||
$req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org');
|
||||
$in = new Rfc9421IncomingSignedRequest($body, $req);
|
||||
$in->setKey($jwk);
|
||||
$in->verify();
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function testTamperedBodyRejected(): void {
|
||||
[$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
|
||||
[$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
|
||||
$signatoryManager = $this->makeSignatoryManager($signatory);
|
||||
|
||||
$body = 'original';
|
||||
|
|
@ -57,7 +79,7 @@ class Rfc9421RoundTripTest extends TestCase {
|
|||
}
|
||||
|
||||
public function testTamperedSignatureRejected(): void {
|
||||
[$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
|
||||
[$signatory, $jwk] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
|
||||
$signatoryManager = $this->makeSignatoryManager($signatory);
|
||||
|
||||
$body = 'msg';
|
||||
|
|
@ -77,7 +99,7 @@ class Rfc9421RoundTripTest extends TestCase {
|
|||
}
|
||||
|
||||
public function testOutgoingUsesOcmLabel(): void {
|
||||
[$signatory] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
|
||||
[$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
|
||||
$signatoryManager = $this->makeSignatoryManager($signatory);
|
||||
|
||||
$out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
|
||||
|
|
@ -89,7 +111,7 @@ class Rfc9421RoundTripTest extends TestCase {
|
|||
}
|
||||
|
||||
public function testRequestWithoutOcmLabelRejected(): void {
|
||||
[$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
|
||||
[$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
|
||||
$signatoryManager = $this->makeSignatoryManager($signatory);
|
||||
|
||||
$out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
|
||||
|
|
@ -109,7 +131,7 @@ class Rfc9421RoundTripTest extends TestCase {
|
|||
// RFC 8941 §4.2 last-wins on duplicate dictionary keys, but OCM
|
||||
// mandates that duplicate `ocm` entries cause the request to be
|
||||
// rejected outright. The model layer enforces that.
|
||||
[$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
|
||||
[$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
|
||||
$signatoryManager = $this->makeSignatoryManager($signatory);
|
||||
|
||||
$out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
|
||||
|
|
@ -125,7 +147,7 @@ class Rfc9421RoundTripTest extends TestCase {
|
|||
}
|
||||
|
||||
public function testForeignSiblingLabelIgnored(): void {
|
||||
[$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
|
||||
[$signatory, $jwk] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
|
||||
$signatoryManager = $this->makeSignatoryManager($signatory);
|
||||
|
||||
$out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
|
||||
|
|
@ -147,7 +169,7 @@ class Rfc9421RoundTripTest extends TestCase {
|
|||
}
|
||||
|
||||
public function testTooOldSignatureRejected(): void {
|
||||
[$signatory] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
|
||||
[$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
|
||||
$signatoryManager = $this->makeSignatoryManager($signatory);
|
||||
|
||||
$body = 'msg';
|
||||
|
|
@ -165,7 +187,7 @@ class Rfc9421RoundTripTest extends TestCase {
|
|||
}
|
||||
|
||||
public function testFutureCreatedRejected(): void {
|
||||
[$signatory] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
|
||||
[$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
|
||||
$signatoryManager = $this->makeSignatoryManager($signatory);
|
||||
|
||||
$body = 'msg';
|
||||
|
|
@ -184,7 +206,7 @@ class Rfc9421RoundTripTest extends TestCase {
|
|||
}
|
||||
|
||||
public function testMissingCreatedRejected(): void {
|
||||
[$signatory] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
|
||||
[$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
|
||||
$signatoryManager = $this->makeSignatoryManager($signatory);
|
||||
|
||||
$body = 'msg';
|
||||
|
|
@ -205,7 +227,7 @@ class Rfc9421RoundTripTest extends TestCase {
|
|||
// A peer that signs only `@method` and `@target-uri`: the body and
|
||||
// freshness window aren't bound. Even with a valid signature we
|
||||
// must refuse it.
|
||||
[$signatory] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
|
||||
[$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
|
||||
$signatoryManager = $this->makeSignatoryManagerWithComponents(
|
||||
$signatory,
|
||||
['@method', '@target-uri'],
|
||||
|
|
@ -220,6 +242,12 @@ class Rfc9421RoundTripTest extends TestCase {
|
|||
new Rfc9421IncomingSignedRequest($body, $req);
|
||||
}
|
||||
|
||||
private function skipUnlessSodium(): void {
|
||||
if (!extension_loaded('sodium')) {
|
||||
$this->markTestSkipped('ext-sodium is not loaded');
|
||||
}
|
||||
}
|
||||
|
||||
private function makeSignatoryManagerWithComponents(Signatory $signatory, array $components): ISignatoryManager {
|
||||
return new class($signatory, $components) implements ISignatoryManager {
|
||||
public function __construct(
|
||||
|
|
@ -250,6 +278,67 @@ class Rfc9421RoundTripTest extends TestCase {
|
|||
};
|
||||
}
|
||||
|
||||
private function makeSignatoryManagerWithSigningAlgorithm(Signatory $signatory, string $signingAlgorithm): ISignatoryManager {
|
||||
return new class($signatory, $signingAlgorithm) implements ISignatoryManager {
|
||||
public function __construct(
|
||||
private Signatory $sig,
|
||||
private string $signingAlgorithm,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getProviderId(): string {
|
||||
return 'test';
|
||||
}
|
||||
|
||||
public function getOptions(): array {
|
||||
return [
|
||||
'algorithm' => SignatureAlgorithm::RSA_SHA256,
|
||||
'digestAlgorithm' => DigestAlgorithm::SHA256,
|
||||
'rfc9421.signingAlgorithm' => $this->signingAlgorithm,
|
||||
];
|
||||
}
|
||||
|
||||
public function getLocalSignatory(): Signatory {
|
||||
return $this->sig;
|
||||
}
|
||||
|
||||
public function getRemoteSignatory(string $remote): ?Signatory {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: Signatory, 1: \Firebase\JWT\Key}
|
||||
*/
|
||||
private function ecdsaP256Material(string $kid): array {
|
||||
$pkey = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']);
|
||||
$privatePem = '';
|
||||
openssl_pkey_export($pkey, $privatePem);
|
||||
$details = openssl_pkey_get_details($pkey);
|
||||
$publicPem = $details['key'];
|
||||
|
||||
$signatory = new Signatory(true);
|
||||
$signatory->setKeyId($kid);
|
||||
$signatory->setPublicKey($publicPem);
|
||||
$signatory->setPrivateKey($privatePem);
|
||||
|
||||
$x = str_pad($details['ec']['x'], 32, "\x00", STR_PAD_LEFT);
|
||||
$y = str_pad($details['ec']['y'], 32, "\x00", STR_PAD_LEFT);
|
||||
$key = JWK::parseKey([
|
||||
'kty' => 'EC',
|
||||
'crv' => 'P-256',
|
||||
'kid' => $kid,
|
||||
'alg' => 'ES256',
|
||||
'x' => self::b64url($x),
|
||||
'y' => self::b64url($y),
|
||||
], 'ES256');
|
||||
return [$signatory, $key];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: Signatory, 1: \Firebase\JWT\Key}
|
||||
*/
|
||||
private function ed25519Material(string $kid): array {
|
||||
$keypair = sodium_crypto_sign_keypair();
|
||||
$publicKey = sodium_crypto_sign_publickey($keypair);
|
||||
|
|
@ -263,11 +352,15 @@ class Rfc9421RoundTripTest extends TestCase {
|
|||
'crv' => 'Ed25519',
|
||||
'kid' => $kid,
|
||||
'alg' => 'EdDSA',
|
||||
'x' => rtrim(strtr(base64_encode($publicKey), '+/', '-_'), '='),
|
||||
'x' => self::b64url($publicKey),
|
||||
], 'EdDSA');
|
||||
return [$signatory, $key];
|
||||
}
|
||||
|
||||
private static function b64url(string $bin): string {
|
||||
return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
private function makeSignatoryManager(Signatory $signatory): ISignatoryManager {
|
||||
return new class($signatory) implements ISignatoryManager {
|
||||
public function __construct(
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ class AlgorithmTest extends TestCase {
|
|||
public function testNormalizeNativeIsPassThrough(): void {
|
||||
$this->assertSame('ed25519', Algorithm::normalize('ed25519'));
|
||||
$this->assertSame('rsa-v1_5-sha256', Algorithm::normalize('rsa-v1_5-sha256'));
|
||||
$this->assertSame('rsa-v1_5-sha384', Algorithm::normalize('rsa-v1_5-sha384'));
|
||||
$this->assertSame('rsa-v1_5-sha512', Algorithm::normalize('rsa-v1_5-sha512'));
|
||||
$this->assertSame('ecdsa-p256-sha256', Algorithm::normalize('ecdsa-p256-sha256'));
|
||||
$this->assertSame('ecdsa-p384-sha384', Algorithm::normalize('ecdsa-p384-sha384'));
|
||||
}
|
||||
|
||||
public function testNormalizeJoseAliases(): void {
|
||||
|
|
@ -53,10 +56,17 @@ class AlgorithmTest extends TestCase {
|
|||
$this->assertNull(Algorithm::deriveJoseAlgFromJwk([]));
|
||||
}
|
||||
|
||||
public function testEd25519RoundTrip(): void {
|
||||
[$priv, $key] = $this->ed25519KeyPair();
|
||||
public function testEd25519SigningIsRejected(): void {
|
||||
$this->expectException(SignatureException::class);
|
||||
$this->expectExceptionMessageMatches('/Ed25519 signing is not supported/');
|
||||
Algorithm::sign('payload', str_repeat("\x00", 64), 'ed25519');
|
||||
}
|
||||
|
||||
public function testEd25519VerifyRoundTripWithSodium(): void {
|
||||
$this->skipUnlessSodium();
|
||||
[$secret, $key] = $this->ed25519KeyPair();
|
||||
$base = 'arbitrary signature base';
|
||||
$sig = Algorithm::sign($base, $priv, 'ed25519');
|
||||
$sig = sodium_crypto_sign_detached($base, $secret);
|
||||
$this->assertSame(64, strlen($sig));
|
||||
$this->assertTrue(Algorithm::verify($base, $sig, $key, 'ed25519'));
|
||||
// JOSE alias accepted.
|
||||
|
|
@ -97,6 +107,7 @@ class AlgorithmTest extends TestCase {
|
|||
}
|
||||
|
||||
public function testAlgHintConflictsWithJwkAlgRejected(): void {
|
||||
$this->skipUnlessSodium();
|
||||
// Ed25519 JWK, request claims ES256: RFC 9421 §3.2 step 6 disagreement.
|
||||
[, $key] = $this->ed25519KeyPair();
|
||||
$this->expectException(SignatureException::class);
|
||||
|
|
@ -104,6 +115,7 @@ class AlgorithmTest extends TestCase {
|
|||
}
|
||||
|
||||
public function testParseKeyRejectsContradictoryAlg(): void {
|
||||
$this->skipUnlessSodium();
|
||||
// kty=OKP/crv=Ed25519 with alg=ES256 is contradictory; firebase's
|
||||
// parseKey rejects it before we ever build a Key.
|
||||
$keypair = sodium_crypto_sign_keypair();
|
||||
|
|
@ -117,14 +129,6 @@ class AlgorithmTest extends TestCase {
|
|||
], null);
|
||||
}
|
||||
|
||||
public function testAlgHintAgreesViaJoseAlias(): void {
|
||||
[$priv, $key] = $this->ed25519KeyPair();
|
||||
$base = 'agreement check';
|
||||
$sig = Algorithm::sign($base, $priv, 'ed25519');
|
||||
$this->assertTrue(Algorithm::verify($base, $sig, $key, 'ed25519'));
|
||||
$this->assertTrue(Algorithm::verify($base, $sig, $key, 'EdDSA'));
|
||||
}
|
||||
|
||||
public function testEcdsaRawToDerProducesValidSignature(): void {
|
||||
[$priv, $key] = $this->ecKeyPair('prime256v1', 'P-256', 'ES256');
|
||||
$rawSig = Algorithm::sign('msg', $priv, 'ecdsa-p256-sha256');
|
||||
|
|
@ -137,6 +141,12 @@ class AlgorithmTest extends TestCase {
|
|||
$this->assertNull(Algorithm::ecdsaRawToDer('short', 32));
|
||||
}
|
||||
|
||||
private function skipUnlessSodium(): void {
|
||||
if (!extension_loaded('sodium')) {
|
||||
$this->markTestSkipped('ext-sodium is not loaded');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: Key}
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -52,9 +52,6 @@ class SignatureManagerDispatchTest extends TestCase {
|
|||
}
|
||||
|
||||
public function testOutgoingDispatchesToCavageByDefault(): void {
|
||||
// Cavage signs with an RSA PEM, so we need a real RSA keypair here;
|
||||
// the Ed25519 helper would produce libsodium bytes that openssl_sign
|
||||
// can't consume.
|
||||
$signatoryManager = $this->rsaSignatoryManager();
|
||||
|
||||
$signed = $this->signatureManager->getOutgoingSignedRequest(
|
||||
|
|
@ -68,7 +65,7 @@ class SignatureManagerDispatchTest extends TestCase {
|
|||
}
|
||||
|
||||
public function testOutgoingDispatchesToRfc9421WhenOptionSet(): void {
|
||||
[$signatoryManager,] = $this->ed25519SignatoryManager(rfc9421Format: true);
|
||||
[$signatoryManager,] = $this->ecdsaP256SignatoryManager(rfc9421Format: true);
|
||||
|
||||
$signed = $this->signatureManager->getOutgoingSignedRequest(
|
||||
$signatoryManager,
|
||||
|
|
@ -84,7 +81,7 @@ class SignatureManagerDispatchTest extends TestCase {
|
|||
}
|
||||
|
||||
public function testInboundDispatchesToRfc9421WhenSignatureInputPresent(): void {
|
||||
[$signatoryManager, $jwk, $secret] = $this->ed25519SignatoryManager(rfc9421Format: true);
|
||||
[$signatoryManager, $jwk] = $this->ecdsaP256SignatoryManager(rfc9421Format: true);
|
||||
|
||||
// Build a real signed request and replay its headers as the inbound
|
||||
// request to exercise the full inbound path including verification.
|
||||
|
|
@ -101,14 +98,14 @@ class SignatureManagerDispatchTest extends TestCase {
|
|||
|
||||
$this->primeRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org');
|
||||
|
||||
$resolver = $this->makeKeyResolver($signatoryManager, $jwk, 'https://sender.example.org/ocm#ed25519');
|
||||
$resolver = $this->makeKeyResolver($signatoryManager, $jwk, 'https://sender.example.org/ocm#ecdsa-p256-sha256');
|
||||
|
||||
$signed = $this->signatureManager->getIncomingSignedRequest($resolver, $body);
|
||||
$this->assertInstanceOf(Rfc9421IncomingSignedRequest::class, $signed);
|
||||
}
|
||||
|
||||
public function testInboundRejectsRfc9421WhenSignatoryManagerCannotResolve(): void {
|
||||
[$signatoryManager,] = $this->ed25519SignatoryManager(rfc9421Format: true);
|
||||
[$signatoryManager,] = $this->ecdsaP256SignatoryManager(rfc9421Format: true);
|
||||
|
||||
$body = '{"hello":"world"}';
|
||||
$out = new Rfc9421OutgoingSignedRequest(
|
||||
|
|
@ -165,26 +162,31 @@ class SignatureManagerDispatchTest extends TestCase {
|
|||
}
|
||||
|
||||
/**
|
||||
* @return array{ISignatoryManager, Key, string} [manager, parsed verification key, raw secret key]
|
||||
* @return array{ISignatoryManager, Key} [manager, parsed verification key]
|
||||
*/
|
||||
private function ed25519SignatoryManager(bool $rfc9421Format): array {
|
||||
$keypair = sodium_crypto_sign_keypair();
|
||||
$publicKey = sodium_crypto_sign_publickey($keypair);
|
||||
$secretKey = sodium_crypto_sign_secretkey($keypair);
|
||||
$kid = 'https://sender.example.org/ocm#ed25519';
|
||||
private function ecdsaP256SignatoryManager(bool $rfc9421Format): array {
|
||||
$pkey = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']);
|
||||
$privatePem = '';
|
||||
openssl_pkey_export($pkey, $privatePem);
|
||||
$details = openssl_pkey_get_details($pkey);
|
||||
$publicPem = $details['key'];
|
||||
$kid = 'https://sender.example.org/ocm#ecdsa-p256-sha256';
|
||||
|
||||
$signatory = new Signatory(true);
|
||||
$signatory->setKeyId($kid);
|
||||
$signatory->setPublicKey($publicKey);
|
||||
$signatory->setPrivateKey($secretKey);
|
||||
$signatory->setPublicKey($publicPem);
|
||||
$signatory->setPrivateKey($privatePem);
|
||||
|
||||
$x = str_pad($details['ec']['x'], 32, "\x00", STR_PAD_LEFT);
|
||||
$y = str_pad($details['ec']['y'], 32, "\x00", STR_PAD_LEFT);
|
||||
$key = JWK::parseKey([
|
||||
'kty' => 'OKP',
|
||||
'crv' => 'Ed25519',
|
||||
'kty' => 'EC',
|
||||
'crv' => 'P-256',
|
||||
'kid' => $kid,
|
||||
'alg' => 'EdDSA',
|
||||
'x' => rtrim(strtr(base64_encode($publicKey), '+/', '-_'), '='),
|
||||
], 'EdDSA');
|
||||
'alg' => 'ES256',
|
||||
'x' => rtrim(strtr(base64_encode($x), '+/', '-_'), '='),
|
||||
'y' => rtrim(strtr(base64_encode($y), '+/', '-_'), '='),
|
||||
], 'ES256');
|
||||
|
||||
$manager = new class($signatory, $rfc9421Format) implements ISignatoryManager {
|
||||
public function __construct(
|
||||
|
|
@ -213,7 +215,7 @@ class SignatureManagerDispatchTest extends TestCase {
|
|||
return null;
|
||||
}
|
||||
};
|
||||
return [$manager, $key, $secretKey];
|
||||
return [$manager, $key];
|
||||
}
|
||||
|
||||
private function makeKeyResolver(ISignatoryManager $delegate, Key $key, string $kid): IJwkResolvingSignatoryManager {
|
||||
|
|
|
|||
Loading…
Reference in a new issue