Proof-of-Destruction Manifests
Status: core capability + retention cron wire-in shipped (NQU-682). PII-scrub wire-in lands with the PII-scrub job (NQU-681).
What
After every retention or PII-scrub cron run, Nquiry produces a signed, immutable JSON manifest describing exactly what was deleted, when, by whom, under what authorization. The manifest is stored in S3 with Object Lock in compliance mode — even AWS account root cannot delete the manifest during its retention window.
This satisfies HIPAA defensible-deletion requirements and FedRAMP AU-11 (Audit Record Retention).
Manifest schema (v1)
{
"manifestVersion": 1,
"runId": "<uuid>",
"runType": "retention" | "pii_scrub",
"startedAt": "<ISO 8601>",
"finishedAt": "<ISO 8601>",
"operator": "cron",
"operatorRunMetadata": { "github_actions_run_id": "...", ... },
"results": [
{
"organizationId": "<uuid>",
"dataCategory": "investigation" | "evidence_attachment" | "audit_log" | "pii",
"rowCount": <int>,
"s3ObjectsDeleted": ["<key>", ...],
"deletedAt": "<ISO 8601>",
"policyIdAtTimeOfRun": "<uuid> | null",
"legalHoldStatus": "none" | "active_until_<iso>"
}
]
}
The signature is NOT part of the JSON payload. It lives in S3 user
metadata (x-amz-meta-signature) and the JSON is signed in its
canonical form (alphabetically-sorted keys at every nesting level).
Signing
Manifests are signed with an asymmetric KMS key:
- Algorithm:
RSASSA_PSS_SHA_256(NIST 800-131A Rev 2 compliant) - Key spec: RSA-2048
- Key alias:
alias/<env>-retention-manifest-signing
The KMS key cannot be deleted within 30 days of any deletion request (deletion window). It does NOT auto-rotate — asymmetric KMS keys don't support automatic rotation, and rotating would invalidate the verification chain for prior manifests. The key ID is stable for the lifetime of the deployment.
Retention
The Object Lock retention-until is computed at write time as the MAX of
all per-org audit_log_retention_days policies for orgs touched by the
run. Default floor: 7 years (FedRAMP AU-11 baseline).
Once written, the manifest cannot be:
- Deleted (compliance-mode Object Lock blocks both versioned and unversioned delete)
- Shortened (compliance mode prevents reducing the retention window)
- Modified (S3 versioning + signature mismatch on tampering)
These guarantees hold even against AWS account root.
Retrieval
# As a platform admin
GET /api/admin/proof-of-destruction?run_id=<uuid>
Returns:
{
"run_id": "...",
"manifest": { ... },
"manifest_json": "...",
"signature": {
"algorithm": "RSASSA_PSS_SHA_256",
"kms_key_id": "alias/...",
"value_base64": "..."
},
"object_lock": {
"retention_until": "...",
"s3_version_id": "..."
},
"verification_hint": "..."
}
Verification (out-of-band, e.g. by outside counsel)
The receiver does not need AWS console access — only the public key
(retrievable via aws kms get-public-key --key-id <kms_key_id>) and a
standard RSA-PSS verifier:
# 1. Fetch the public key
aws kms get-public-key --key-id alias/nquir-retention-signing \
--query PublicKey --output text | base64 -d > pubkey.der
# 2. Convert DER to PEM (optional)
openssl rsa -pubin -inform DER -in pubkey.der -out pubkey.pem
# 3. Verify (manifest_json must be the canonical form returned by the API)
echo -n "$MANIFEST_JSON" > manifest.txt
echo "$SIGNATURE_BASE64" | base64 -d > manifest.sig
openssl dgst -sha256 -sigopt rsa_padding_mode:pss -sigopt rsa_pss_saltlen:-1 \
-verify pubkey.pem -signature manifest.sig manifest.txt
# → "Verified OK"
Operator notes
- If Object Lock pre-flight fails at cron start: the cron returns
HTTP 500 immediately and refuses to delete any data. No DB queries, no
S3 deletes, no audit rows. Better to skip a run than create deletions
without immutable evidence. The pre-flight runs at the very top of
every retention cron invocation (before the abandonment query) when
RETENTION_MANIFEST_BUCKETandRETENTION_MANIFEST_KMS_KEY_IDare set. Diagnose by checking the bucket has Object Lock enabled (it is irreversibly enabled at terraform-apply time; if disabled, the bucket was recreated outside terraform). - If env vars are not set: the cron logs a warning and continues in unprotected mode. This is the dev escape valve so a fresh environment can run the cron before terraform apply. Production must always have both env vars set; an unprotected production run is a compliance defect to file as a follow-up.
- If a cron run completes but the manifest emit fails (post-deletion):
the run's deletions are committed but the immutable record is missing.
The cron fails-loud (HTTP 500) so on-call sees it. Operator must
re-run the manifest emission for the same
runId(it's idempotent). Distinct from pre-flight: pre-flight prevents this case for Object-Lock-side problems but a transient KMS or upload failure can still hit here. - Manifest cost: each manifest is a few KB. Object Lock storage is standard S3 pricing for the retention window.