Skip to content

Commit 6ec24de

Browse files
feat(webhooks): provision pending signing secrets
1 parent 1f93e3e commit 6ec24de

17 files changed

Lines changed: 1154 additions & 33 deletions

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,19 @@ secret internamente, persiste apenas seu envelope AES-256-GCM autenticado e
172172
devolve metadados redigidos. O endpoint nasce pendente; sua ativação depende do
173173
challenge explícito, que não é disparado silenciosamente durante o cadastro.
174174

175+
Antes do challenge, o administrador deve provisionar a chave de verificação no
176+
receptor por `POST /v1/webhooks/endpoints/{endpointId}/signing-secrets`, enviando
177+
`baseRevision` e `Idempotency-Key`. A primeira resposta devolve
178+
`secretBase64url` e `secretAvailable: true`; esse valor representa exatamente os
179+
32 bytes da chave HMAC em Base64URL e deve ser transferido para o secret manager
180+
do receptor sem entrar em logs. Replay da mesma requisição devolve os mesmos
181+
metadados, mas nunca repete a chave (`secretAvailable: false`). Se a primeira
182+
resposta for perdida, deve-se consultar a nova revisão do endpoint e provisionar
183+
outra versão com uma nova chave idempotente. A versão anterior é aposentada na
184+
mesma transação. Neste incremento, o command só aceita endpoint ainda pendente;
185+
rotação de endpoint ativo exige overlap para proteger deliveries em voo e será
186+
um contrato separado.
187+
175188
A ativação é solicitada por
176189
`POST /v1/webhooks/endpoints/{endpointId}/challenge`, sem body. O Apollo faz um
177190
POST HTTPS para a URL cadastrada com JSON canônico no formato

TODO.md

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,7 @@
496496
- [x] Modelar endpoint, subscription, secret, filter e delivery attempt. Evidência F0-034: domínios canônicos, registro transacional, cinco tabelas, constraints e regressões de segurança.
497497
- [x] Implementar challenge, assinatura, timestamp e anti-replay. Evidência F0-035/F0-036: challenge durável one-shot, HMAC dos bytes exatos, janela de timestamp, receipt anti-replay e transporte HTTPS pinado com resolução DNS fail-closed.
498498
- [x] Implementar at-least-once, backoff, dead-letter e replay controlado. Evidência F0-031/F0-046: render possui lease/fencing, backoff, checkpoint, dead-letter e retry manual; webhooks possuem outbox/fan-out, claim/lease/fencing, dispatch assinado, transporte DNS-pinado, heartbeat, discovery, coordenação durável de shards, secret provider configurado, entrypoint operacional, backoff, dead-letter e replay idempotente individual ou por evento exato. Replay por intervalo permanece enhancement administrativo separado.
499-
- [ ] Criar UI/API administrativa de status, attempts e rotação de secret. Parcial F0-042/F0-044/F0-047/F0-048/F0-049/F0-050/F0-051/F0-052: API externa cria/lista/lê endpoints e subscriptions, consulta deliveries, executa challenge/ativação, replay e lifecycle; UI e rotação de secret continuam abertas.
499+
- [ ] Criar UI/API administrativa de status, attempts e rotação de secret. Parcial F0-042/F0-044/F0-047/F0-048/F0-049/F0-050/F0-051/F0-052/F0-053: API externa cria/lista/lê endpoints e subscriptions, provisiona chave HMAC pendente, executa challenge/ativação, replay e lifecycle; UI e rotação com overlap de endpoint ativo continuam abertas.
500500
- [x] Criar integration tests de duplicação, timeout, assinatura inválida e replay. Evidência F0-035/F0-043: assinatura adulterada, anti-replay durável, deadline absoluto, DNS/rebinding, claim concorrente, lease/fencing, retry/dead-letter e replay administrativo idempotente estão cobertos em contratos, Prisma e HTTP.
501501

502502
### F0.039 — Idempotência e concorrência externa [FR-245]
@@ -539,7 +539,7 @@
539539
### F0.043 — Governança da API [FR-249]
540540

541541
- [ ] Criar administração de clients, scopes, secrets, environments e status.
542-
- [ ] Criar administração de webhooks, subscriptions e delivery diagnostics. Parcial F0-042/F0-044/F0-047/F0-048/F0-049/F0-050/F0-051/F0-052: capabilities entregam cadastro idempotente, consultas, challenge/ativação, lifecycle com cascatas e replay workspace-scoped; rotação e UI continuam abertas.
542+
- [ ] Criar administração de webhooks, subscriptions e delivery diagnostics. Parcial F0-042/F0-044/F0-047/F0-048/F0-049/F0-050/F0-051/F0-052/F0-053: capabilities entregam cadastro idempotente, provisionamento one-shot da chave pendente, consultas, challenge/ativação, lifecycle com cascatas e replay workspace-scoped; rotação ativa e UI continuam abertas.
543543
- [ ] Implementar rate limits, quotas, concurrency e spend budgets por client/workspace.
544544
- [ ] Criar usage e audit queries paginadas com redaction.
545545
- [ ] Criar sandbox isolado com provider fakes e custos simulados.
@@ -4093,7 +4093,7 @@ Limites explícitos desta slice:
40934093

40944094
### Slice F0-052 — Challenge público e ativação convergente de endpoint
40954095

4096-
**Status:** concluído localmente em 15 de julho de 2026; ainda não commitado.
4096+
**Status:** publicado no `main` em 15 de julho de 2026 (`1f93e3e`); hosted CI `29449277660` aprovada.
40974097

40984098
Entregas:
40994099

@@ -4125,3 +4125,38 @@ Limites explícitos desta slice:
41254125
- o challenge é síncrono e limitado pelo deadline; operação assíncrona será necessária apenas se providers futuros excederem essa janela;
41264126
- alteração de URL e rotação de signing secret permanecem commands separados futuros;
41274127
- UI administrativa, audit query, métricas, circuit breaker e alertas continuam futuros.
4128+
4129+
### Slice F0-053 — Provisionamento one-shot da chave HMAC pendente
4130+
4131+
**Status:** concluído localmente em 15 de julho de 2026; ainda não commitado.
4132+
4133+
Entregas:
4134+
4135+
- capability `apollo.webhooks.endpoints.signing-secrets.provision` expõe `POST /v1/webhooks/endpoints/{endpointId}/signing-secrets` sob `webhooks:admin`;
4136+
- o fluxo corrige o pré-requisito criptográfico do HMAC: o receptor passa a receber os 32 bytes necessários para verificar deliveries;
4137+
- body fechado exige `baseRevision` e o header exige `Idempotency-Key`;
4138+
- somente endpoint `pending-verification` pode provisionar; endpoint ativo, suspenso ou revogado falha antes da mutação;
4139+
- nova chave é gerada, fingerprinted e cifrada em AES-256-GCM; a versão ativa anterior é aposentada e a nova versão é persistida atomicamente;
4140+
- endpoint recebe nova revisão na mesma transação, impedindo challenge ou provisionamentos concorrentes sobre estado obsoleto;
4141+
- primeira resposta retorna 201, `secretBase64url` one-shot e `secretAvailable: true`;
4142+
- replay idêntico retorna 200 com o mesmo endpoint/secret metadata, `secretAvailable: false` e sem material HMAC;
4143+
- mesma chave idempotente com revisão diferente retorna conflito de payload; nova chave com revisão antiga retorna conflito de revisão;
4144+
- ledger persiste apenas IDs e metadados seguros, nunca a chave retornada;
4145+
- ADR-042 formaliza provisionamento, recuperação após resposta perdida e separação da rotação ativa com overlap.
4146+
4147+
Regressões e evidências locais:
4148+
4149+
- suíte global passa com 115 testes; 47 são contratos de webhook;
4150+
- testes cobrem disclosure único, bytes temporários zerados, replay redigido e validação antes da geração;
4151+
- integração Prisma cobre retirement da v1, criação/abertura cifrada da v2, replay, conflitos, ledger redigido e revisão obsoleta;
4152+
- jornada HTTP cobre OpenAPI, 201/200, chave one-shot, fingerprint, body/header inválido, endpoint ativo, revisão antiga e 403 sem scope;
4153+
- contratos públicos passam com 41 capabilities, 51 schemas, 70 exemplos e 36 paths;
4154+
- build registra `POST /v1/webhooks/endpoints/{endpointId}/signing-secrets`;
4155+
- schema/migration permanecem com 27 tabelas, 102 índices e 57 chaves estrangeiras.
4156+
4157+
Limites explícitos desta slice:
4158+
4159+
- perda da primeira resposta exige consultar a revisão atual e gerar outra versão; a chave nunca pode ser recuperada novamente;
4160+
- endpoint ativo ainda não pode rotacionar porque deliveries em voo exigem período de overlap e escolha de versão explícita;
4161+
- o consumidor deve decodificar Base64URL para os 32 bytes HMAC e armazená-los em seu próprio secret manager;
4162+
- alteração de URL, UI administrativa, audit query, métricas e alertas continuam futuros.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# ADR-042 — Provisionamento one-shot da chave HMAC pendente
2+
3+
> **Status:** Accepted
4+
>
5+
> **Data:** 15 de julho de 2026
6+
7+
## Contexto
8+
9+
O Apollo gerava e cifrava o signing secret de cada endpoint, mas não existia um canal para provisionar a mesma chave no receptor. Como HMAC exige segredo compartilhado, o receptor podia responder ao challenge, porém não conseguia verificar assinaturas das deliveries. Expor novamente uma chave após replay também criaria risco de persistência acidental em ledger, cache ou logs.
10+
11+
## Decisão
12+
13+
- A capability `apollo.webhooks.endpoints.signing-secrets.provision` expõe `POST /v1/webhooks/endpoints/{endpointId}/signing-secrets` sob `webhooks:admin` e confirmação humana.
14+
- O body contém somente `baseRevision`; `Idempotency-Key` é obrigatória e vinculada a workspace, actor, endpoint e revisão solicitada.
15+
- Somente endpoint `pending-verification` pode usar o command. Assim, nenhuma delivery assinada pode estar em voo durante a substituição.
16+
- A operação lê a maior versão, gera 32 bytes aleatórios para a próxima, calcula SHA-256, cifra o valor Base64URL com AES-256-GCM e contexto autenticado e zera o buffer temporário.
17+
- Na mesma transação serializável, o endpoint avança de revisão, o único secret ativo é aposentado, a nova versão e seu payload cifrado são criados e o ledger é concluído.
18+
- A primeira resposta retorna `secretBase64url`, `secretAvailable: true` e 201. Esse é o único momento em que a chave deixa a fronteira protegida do Apollo.
19+
- Replay idêntico retorna 200, os mesmos metadados, `secretAvailable: false` e omite completamente `secretBase64url`.
20+
- O ledger armazena apenas `endpointId` e `secretId`. Plaintext, Base64URL, `keyRef`, nonce, ciphertext e auth tag não entram na resposta persistida.
21+
- Se a primeira resposta for perdida, a chave não é recuperada. O administrador consulta a nova revisão e executa outro provisionamento com nova `Idempotency-Key`, aposentando a versão desconhecida.
22+
- Endpoint ativo, suspenso ou revogado retorna conflito. Rotação ativa será outro contrato, com validade sobreposta e versão de assinatura explícita para proteger deliveries em voo.
23+
24+
## Consequências
25+
26+
- O workflow externo completo passa a ser: criar endpoint, provisionar chave no receptor, configurar verificação HMAC, executar challenge e somente então receber deliveries.
27+
- Agentes externos podem automatizar o provisionamento sem acesso ao banco, `keyRef` ou chave mestra do Apollo.
28+
- A resposta one-shot deve usar `Cache-Control: no-store`, TLS e um cliente que não registre bodies sensíveis.
29+
- A versão v1 criada junto do endpoint pode ser aposentada sem nunca ter sido divulgada; a primeira chave operacional normalmente será v2.
30+
31+
## Evidências exigidas
32+
33+
- primeira resposta contém exatamente 32 bytes em Base64URL e fingerprint correspondente;
34+
- replay nunca contém a chave e devolve o mesmo secret ID/version;
35+
- ledger e payload durável não contêm plaintext;
36+
- retirement, criação do payload, nova revisão e idempotência são atômicos;
37+
- revisão antiga, lifecycle incompatível, workspace ausente e falta de scope falham fechado;
38+
- bytes temporários são zerados e provider abre somente a versão ativa exata;
39+
- OpenAPI, schemas, exemplos, Prisma e jornada HTTP descrevem o mesmo contrato.

docs/adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,4 @@ Estados usados:
4343
- [ADR-039 — Criação idempotente de subscriptions de webhook](./ADR-039-idempotent-webhook-subscription-creation.md)
4444
- [ADR-040 — Cadastro de endpoint com secret dinâmico cifrado](./ADR-040-encrypted-dynamic-webhook-endpoint-registration.md)
4545
- [ADR-041 — Challenge público e ativação convergente de webhook](./ADR-041-public-webhook-challenge-and-convergent-activation.md)
46+
- [ADR-042 — Provisionamento one-shot da chave HMAC pendente](./ADR-042-one-time-pending-webhook-secret-provisioning.md)
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { randomUUID } from 'node:crypto'
2+
3+
import { NextRequest, NextResponse } from 'next/server'
4+
5+
import { requireScope } from '@/v2/application/authenticate-api-client'
6+
import { provisionWebhookSigningSecretService } from '@/v2/application/provision-webhook-signing-secret'
7+
import { readWebhookEndpointService } from '@/v2/application/read-webhook-administration'
8+
import { DomainError } from '@/v2/domain/errors'
9+
import {
10+
createConfiguredWebhookSigningSecretProtector,
11+
createWebhookAdministrationQueryRepository,
12+
createWebhookSigningSecretProvisioningRepository,
13+
} from '@/v2/infrastructure/repository-factory'
14+
import { authenticateExternalRequest } from '@/v2/public-api/authentication'
15+
import { publicApiHeaders, resolveRequestId, respondPublicError } from '@/v2/public-api/errors'
16+
import { presentSuccess, presentWebhookEndpointSummary } from '@/v2/public-api/presenters'
17+
18+
export const dynamic = 'force-dynamic'
19+
20+
export async function POST(
21+
request: NextRequest,
22+
context: { params: Promise<{ endpointId: string }> },
23+
) {
24+
const requestId = resolveRequestId(request)
25+
try {
26+
const actor = await authenticateExternalRequest(request)
27+
requireScope(actor, 'webhooks:admin')
28+
let body: { baseRevision?: unknown }
29+
try {
30+
body = await request.json() as typeof body
31+
} catch {
32+
throw new DomainError('INVALID_ARGUMENT', 'Request body must be valid JSON')
33+
}
34+
if (
35+
typeof body !== 'object' ||
36+
body === null ||
37+
Array.isArray(body) ||
38+
Object.keys(body).sort().join(',') !== 'baseRevision' ||
39+
typeof body.baseRevision !== 'string'
40+
) {
41+
throw new DomainError(
42+
'INVALID_ARGUMENT',
43+
'Request body must contain only baseRevision',
44+
)
45+
}
46+
const { endpointId } = await context.params
47+
const provision = provisionWebhookSigningSecretService({
48+
repository: createWebhookSigningSecretProvisioningRepository(),
49+
secrets: createConfiguredWebhookSigningSecretProtector(),
50+
clock: () => new Date(),
51+
createId: () => randomUUID(),
52+
})
53+
const result = await provision({
54+
workspaceId: actor.workspaceId,
55+
endpointId,
56+
actorClientId: actor.clientId,
57+
baseRevision: body.baseRevision,
58+
idempotencyKey: request.headers.get('idempotency-key') ?? '',
59+
})
60+
const read = readWebhookEndpointService({
61+
repository: createWebhookAdministrationQueryRepository(),
62+
})
63+
const endpoint = await read({ workspaceId: actor.workspaceId, endpointId })
64+
return NextResponse.json(
65+
presentSuccess({
66+
endpoint: presentWebhookEndpointSummary(endpoint),
67+
...(result.secretAvailable
68+
? { secretBase64url: result.secretBase64url }
69+
: {}),
70+
secretAvailable: result.secretAvailable,
71+
replayed: result.replayed,
72+
}),
73+
{
74+
status: result.replayed ? 200 : 201,
75+
headers: publicApiHeaders(requestId),
76+
},
77+
)
78+
} catch (error) {
79+
return respondPublicError(error, requestId)
80+
}
81+
}

src/v2/application/ports/webhook-signing-secret-protector.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,16 @@ export interface ProtectedWebhookSigningSecretMaterial {
1414
payload: Readonly<WebhookSigningSecretPayload>
1515
}
1616

17+
export interface DisclosedWebhookSigningSecretMaterial
18+
extends ProtectedWebhookSigningSecretMaterial {
19+
secretBase64url: string
20+
}
21+
1722
export interface WebhookSigningSecretProtector {
1823
protect(
1924
request: Readonly<ProtectWebhookSigningSecretRequest>,
2025
): Promise<Readonly<ProtectedWebhookSigningSecretMaterial>>
26+
protectForOneTimeDisclosure(
27+
request: Readonly<ProtectWebhookSigningSecretRequest>,
28+
): Promise<Readonly<DisclosedWebhookSigningSecretMaterial>>
2129
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { WebhookEndpoint, WebhookSigningSecret } from '../../domain/webhook.ts'
2+
import type { WebhookSigningSecretPayload } from '../../domain/webhook-signing-secret-payload.ts'
3+
4+
export interface WebhookSigningSecretProvisioningTarget {
5+
endpoint: Readonly<WebhookEndpoint>
6+
latestSecretVersion: number
7+
}
8+
9+
export interface WebhookSigningSecretProvisioningCommand {
10+
workspaceId: string
11+
endpointId: string
12+
actorClientId: string
13+
baseRevision: string
14+
secret: Readonly<WebhookSigningSecret>
15+
secretPayload: Readonly<WebhookSigningSecretPayload>
16+
idempotency: Readonly<{
17+
id: string
18+
key: string
19+
requestFingerprint: string
20+
requestedAt: string
21+
expiresAt: string
22+
}>
23+
}
24+
25+
export interface WebhookSigningSecretProvisioningResult {
26+
endpoint: Readonly<WebhookEndpoint>
27+
secret: Readonly<WebhookSigningSecret>
28+
replayed: boolean
29+
}
30+
31+
export interface WebhookSigningSecretProvisioningRepository {
32+
getTarget(
33+
workspaceId: string,
34+
endpointId: string,
35+
): Promise<Readonly<WebhookSigningSecretProvisioningTarget> | null>
36+
provisionOrReplay(
37+
command: Readonly<WebhookSigningSecretProvisioningCommand>,
38+
): Promise<Readonly<WebhookSigningSecretProvisioningResult>>
39+
}

0 commit comments

Comments
 (0)