Developer docs
Verifying a sealed record
Every sealed readiness report is a frozen document with a SHA-256 digest, and the digest is signed with a key that lives in Azure Key Vault — a key DeploySeal can use but cannot read, export or replace. Anyone holding the evidence package can check both without an account, without the network, and without trusting DeploySeal's database. This page states exactly what is signed and how, so you can reproduce the check with your own tools.
What is signed
The signed payload is the manifest: the canonical JSON the record was frozen as, stored byte for byte and never re-serialised. It carries the scope, tasks, results, verdicts, issues with their evidence fingerprints, accepted risks, retests, safeguards, the required signers and the version number. It does not carry the signature block — approvals, seal state and the digest signature ride beside the manifest, so every signer covers the same bytes and adding a countersignature never changes what was signed.
digest = SHA-256(manifest.json bytes) lowercase hex; the same value each approval names as reportDigest signature = Sign(privateKey, digest) ES256 (P-256 ECDSA) preferred; RS256 when the vault key is RSA
- ES256: ECDSA over P-256. The signature is the raw concatenation r || s — two 32-byte big-endian integers, 64 bytes in total, the JWS / IEEE P1363 form — not ASN.1 DER.
- RS256: RSASSA-PKCS1-v1_5 with SHA-256. The DigestInfo wrapper is added by the signer, so the signature is what any RSA library produces for the manifest bytes.
- Signing the digest directly is exactly what “ECDSA / RSA with SHA-256 over the manifest bytes” does, so verify(sha256, manifestBytes) in any library is the same check as verifying the digest.
- Encodings: the signature is base64url without padding; the digest is lowercase hex; the public key is a JWK ({kty:"EC",crv:"P-256",x,y} or {kty:"RSA",n,e}) with base64url coordinates.
Obtaining the public key
You never need vault access. The public key is captured as a JWK at the moment of signing and stored beside the signature; it is embedded in signatures.json in every evidence package and returned as publicKeyJwk by the verify endpoint. Each signature carries its own keyId (the Key Vault key identifier URL, pinned to a key version), so keys can rotate: a new key version signs from then on, and every earlier signature still verifies with the JWK it was made with. The verify endpoint additionally reports keyStillPresent — a best-effort cross-check that the vault still serves the same key material — but the verification itself never depends on it.
The evidence package
Export → Evidence package (.zip) on a sealed version (Owner, Admin or Campaign Manager) downloads a self-verifying bundle:
deployseal-<campaign>-evidence-v2.zip ├── manifest.json the sealed record, byte for byte as stored — THIS is what was signed ├── signatures.json digest signature (alg, key id, public key JWK, signature) + approvals + waivers ├── audit-events.json the organisation's audit events about this campaign, up to the seal (bounded) ├── artifacts/ every screenshot / replay / attachment the manifest names, as stored │ ├── 7-screenshot-9f1c2a4b8d3e.png │ └── MISSING.txt (only when something named by the record could not be included, and why) ├── report.pdf the readiness report for this version — a rendering; the manifest is the record ├── verify.mjs the offline verifier below ├── README-verify.md these instructions, with this version's key id and digest filled in └── checksums.txt sha256sum-format checksums of everything above
signatures.json
{
"format": "deployseal-evidence-package/1",
"campaignId": "1a2b3c4d-…", "campaignName": "Spring checkout release", "version": 2,
"manifestVersion": 14,
"digestSha256": "4b8d2f6a0c3e5a7b9d1f3c5e7a9b1d3f5c7e9a1b3d5f7c9e1a3b5d7f9c1e3a5b",
"snapshotCreatedAt": "2026-09-04T13:41:02.118Z", "sealedAt": "2026-09-04T13:47:30.502Z",
"snapshotSignature": {
"algorithm": "ES256",
"keyId": "https://deployseal-kv.vault.azure.net/keys/record-signing/6f2a…c1d4",
"publicKeyJwk": { "kty": "EC", "crv": "P-256", "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU", "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0" },
"signature": "DtEhU3ljbEg8L38VWAfUAqOyKAM6-Xx-F4GawxaepmXFCgfTjDxw5djxLa8ISlSApmWQxfKTUJqPP3-Kg6NU1Q",
"signedAt": "2026-09-04T13:41:02.300Z",
"signerVersion": "deployseal-signer/1",
"backfilled": false
},
"approvals": [ { "signedByName": "Marc Patel", "signedByRole": "Admin", "decision": "Approve", "conditions": [],
"statementText": "…", "signedAt": "2026-09-04T13:47:30.502Z", "assuranceLevel": "org-login-passkey",
"reportVersion": 2, "reportDigest": "4b8d2f6a…3a5b", "signedFromIp": "…", "signedUserAgent": "…" } ],
"waivers": []
}Verify offline — Node
The package ships verify.mjs; it needs Node 18+ and nothing else. Run it in the unzipped folder — exit code 0 means every check passed. This is the complete script, reproduced so you can read what it does before you trust it.
node verify.mjs PASS manifest.json hashes to the recorded digest — sha256:4b8d2f6a… PASS signature verifies (ES256, key https://…/keys/record-signing/6f2a…) — signed 2026-09-04T13:41:02.300Z PASS approval by Marc Patel (Approve, 2026-09-04T13:47:30.502Z) covers this digest PASS checksum manifest.json PASS checksum signatures.json PASS checksum artifacts/7-screenshot-9f1c2a4b8d3e.png … PASS artifact #7 Screenshot 9f1c2a4b8d3e… — artifacts/7-screenshot-9f1c2a4b8d3e.png All checks passed.
#!/usr/bin/env node
// DeploySeal evidence package verifier. No dependencies; Node 18 or newer.
//
// node verify.mjs [package-directory] (default: the directory this file is in)
//
// What it checks, independently of DeploySeal:
// 1. SHA-256(manifest.json bytes) equals the digest in signatures.json.
// 2. The detached signature verifies over those bytes with the public key
// (JWK) embedded in signatures.json. ES256 = ECDSA P-256, raw r||s
// (JWS / IEEE P1363); RS256 = RSASSA-PKCS1-v1_5. Both use SHA-256, and
// "sign the digest" is exactly what verify('sha256', bytes) checks.
// 3. Every approval in signatures.json is bound to that same digest.
// 4. checksums.txt matches every file in the package.
// 5. Every evidence artifact the manifest names is present with its SHA-256.
import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const dir = process.argv[2] ?? dirname(fileURLToPath(import.meta.url))
const read = (name) => readFileSync(join(dir, name))
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex')
const fromBase64Url = (s) => Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64')
let failures = 0
function check(ok, label, detail = '') {
console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${detail ? ` — ${detail}` : ''}`)
if (!ok) failures++
}
// 1. The digest is over the exact stored bytes of manifest.json.
const manifest = read('manifest.json')
const sigs = JSON.parse(read('signatures.json').toString('utf8'))
const digest = sha256(manifest)
check(digest === sigs.digestSha256, 'manifest.json hashes to the recorded digest', `sha256:${digest}`)
// 2. The detached signature, verified with the embedded public key only.
const s = sigs.snapshotSignature
if (!s) {
check(false, 'snapshot signature present', 'this version was recorded before signing existed and has not been backfilled')
} else {
let ok = false
try {
const key = createPublicKey({ key: s.publicKeyJwk, format: 'jwk' })
const options = s.algorithm === 'ES256' ? { key, dsaEncoding: 'ieee-p1363' } : { key }
ok = cryptoVerify('sha256', manifest, options, fromBase64Url(s.signature))
} catch (err) {
console.log(` ${err.message}`)
}
const when = s.backfilled ? `BACKFILLED at ${s.signedAt}: attests to the bytes as of then, not at sealing` : `signed ${s.signedAt}`
check(ok, `signature verifies (${s.algorithm}, key ${s.keyId})`, when)
}
// 3. Each approval names this digest as what it covered.
for (const a of sigs.approvals ?? []) {
check(a.reportDigest === digest, `approval by ${a.signedByName} (${a.decision}, ${a.signedAt}) covers this digest`)
}
// 4. checksums.txt (sha256sum format: "<hex> <path>").
for (const line of read('checksums.txt').toString('utf8').split('\n').filter(Boolean)) {
const hash = line.slice(0, 64)
const name = line.slice(66)
if (name === 'checksums.txt') continue
check(existsSync(join(dir, name)) && sha256(read(name)) === hash, `checksum ${name}`)
}
// 5. Artifacts named in the manifest are present with their fingerprints.
const files = existsSync(join(dir, 'artifacts')) ? readdirSync(join(dir, 'artifacts')) : []
const parsed = JSON.parse(manifest.toString('utf8'))
for (const issue of parsed.report?.issueRegister ?? []) {
for (const art of issue.artifacts ?? []) {
const file = files.find((f) => f.startsWith(`${issue.seqNumber}-`) && f.includes(art.sha256.slice(0, 12)))
const ok = !!file && sha256(read(join('artifacts', file))) === art.sha256
check(ok, `artifact #${issue.seqNumber} ${art.kind} ${art.sha256.slice(0, 12)}…`, file ? `artifacts/${file}` : 'missing (see artifacts/MISSING.txt)')
}
}
console.log(failures === 0 ? '\nAll checks passed.' : `\n${failures} check(s) FAILED.`)
process.exit(failures === 0 ? 0 : 1)Verify offline — openssl
The same checks with sha256sum and openssl. Two conversions are needed because openssl wants a PEM key and, for ECDSA, a DER-encoded signature; the one-liners use Node only as a converter.
# 1. The digest: SHA-256 of manifest.json exactly as shipped — compare with digestSha256
sha256sum manifest.json
# 2. Public key: the embedded JWK → PEM (one line of Node; no packages)
node -e "const s=require('./signatures.json').snapshotSignature;console.log(require('crypto').createPublicKey({key:s.publicKeyJwk,format:'jwk'}).export({type:'spki',format:'pem'}))" > key.pem
# 3. Signature: base64url → binary
node -e "const s=require('./signatures.json').snapshotSignature.signature;process.stdout.write(Buffer.from(s.replace(/-/g,'+').replace(/_/g,'/'),'base64'))" > sig.bin
# 4a. ES256: the 64-byte r||s must be wrapped as DER for openssl
node -e "const b=require('fs').readFileSync('sig.bin');const i=x=>{let h=x.toString('hex').replace(/^(00)+/,'');if(parseInt(h[0],16)>=8)h='00'+h;return Buffer.from(h,'hex')};const r=i(b.subarray(0,32)),s=i(b.subarray(32));const q=Buffer.concat([Buffer.from([2,r.length]),r,Buffer.from([2,s.length]),s]);process.stdout.write(Buffer.concat([Buffer.from([0x30,q.length]),q]))" > sig.der
openssl dgst -sha256 -verify key.pem -signature sig.der manifest.json
# → Verified OK
# 4b. RS256: the signature is already in the form openssl expects
openssl dgst -sha256 -verify key.pem -signature sig.bin manifest.json
# 5. Everything else in the package
sha256sum -c checksums.txtVerify online
Signed in, the verify endpoint (linked from every report's Verification and limitations section) recomputes the digest over the stored bytes and checks the signature with the stored public key. An operator who edited both the manifest and its digest column would still fail it: they cannot produce a signature the vault key would have made.
GET /api/campaigns/{id}/report/versions/{n}/verify
{
"version": 2,
"storedDigest": "4b8d2f6a…3a5b",
"computedDigest": "4b8d2f6a…3a5b",
"valid": true, // digestMatches && signatureValid (digestMatches alone for an unsigned legacy version)
"isLatest": true,
"createdAt": "2026-09-04T13:41:02.118Z",
"digestMatches": true, // SHA-256 of the stored bytes equals the stored digest
"hasSignature": true,
"signatureValid": true, // verified with the STORED public key, never the vault
"algorithm": "ES256",
"keyId": "https://deployseal-kv.vault.azure.net/keys/record-signing/6f2a…c1d4",
"signedAt": "2026-09-04T13:41:02.300Z",
"backfilled": false,
"publicKeyJwk": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" },
"keyStillPresent": true, // best effort: the vault still serves this key material; null when not checkable
"signerVersion": "deployseal-signer/1"
}What “backfilled” means
Records sealed before signed digests existed have a digest and approvals but no signature. An Owner can run the signing backfill, which signs those stored bytes with the current key and marks the signature backfilled: true. Such a signature proves the bytes have not changed since the backfill — not since the seal. The approvals still bind to the digest at sealing time, and the report cover says Digest recorded, not yet signed until the backfill has run. A version with no signature at all fails the signature check in verify.mjs by design; the digest and approval checks still run.
What the seal does not prove
A valid signature proves one thing precisely: this document has not changed since it was signed, and the digest the approvers put their names to is the digest of these bytes. It does not prove:
- That the release contains no defects. The record is evidence of what was tested, found and accepted — testing covered the listed tasks and pages only.
- That testers tested well, or that a passed task means the feature works. It means a named person recorded a pass at a time, on a version.
- That declared launch safeguards exist. Safeguards are attestations by the team, recorded with who declared them and when; DeploySeal never verifies them.
- That the people who signed were who they said. The assurance level on each approval says how the session was authenticated (email-confirmed login, SSO, passkey, step-up code); it is not an identity check.
- Anything after the seal. A sealed version is frozen; a reopen cuts the next version. The signature covers exactly one version and says nothing about later ones.
- Backfilled signatures attest that the bytes had not changed as of the backfill, not as of the seal. Approvals still bind to the digest at sealing time.
Building on the API? Webhooks and the public API are documented alongside.