Signing Requests from a Deployment
A deployment often needs to call your own backend. How does the backend know the request really came from a processor running your deployment, and not from anyone who read your code? Every processor holds a set of per-deployment keys whose public halves are recorded on-chain, so the deployment can sign each request and the backend can verify it against the chain.
This example shows the working path with the P-256 (secp256r1) key in the Node.js runtime, and the three details that trip people up:
- The signer is
_STD_.signers.secp256r1, but the public key isgetPublicKeys().p256. Same key, different names. secp256r1.sign()does not hash. Pass a 32-byte SHA-256 digest.- The signature is raw
r || s(64 bytes), not DER.
_STD_.chains.substrate.signer for this_STD_.chains.substrate.signer.setSigner('SECP256R1') throws
Unknown Substrate signer SECP256R1. That API selects the key used for
Substrate extrinsics, and its curve ids are 'P256', 'SECP256K1',
'ED25519'. For signing arbitrary bytes use _STD_.signers.* as shown below.
1. Sign on the processor
const { createHash } = require('crypto');
const BACKEND = 'https://api.example.com';
async function signedFetch(path, body) {
const timestamp = Date.now().toString();
const bodyText = JSON.stringify(body);
// Canonical message: method, path, timestamp, body. Keep it deterministic.
const message = ['POST', path, timestamp, bodyText].join('\n');
// ECDSA signers sign a *digest*: hash first, then sign the 32-byte hash.
const digestHex = createHash('sha256').update(message).digest('hex');
const signatureHex = _STD_.signers.secp256r1.sign(digestHex); // raw r||s, 64 bytes
// Compressed P-256 public key (33 bytes, hex). Also on-chain as SECP256r1.
const publicKeyHex = _STD_.job.getPublicKeys().p256;
return fetch(BACKEND + path, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-acurast-pubkey': publicKeyHex,
'x-acurast-timestamp': timestamp,
'x-acurast-signature': signatureHex,
},
body: bodyText,
});
}
signedFetch('/ingest', { temperature: 21.5 }).then((r) => console.log(r.status));
The hash step is also available without Node's crypto module as
_STD_.chains.bitcoin.signer.sha256(hex), which takes and returns hex.
2. Verify on the server
Node's crypto.verify accepts the raw r || s encoding directly when
dsaEncoding is set to ieee-p1363. The compressed public key needs to be
wrapped in a SubjectPublicKeyInfo (SPKI) structure to become a KeyObject; the
snippet below builds it by hand so no extra dependency is needed.
const crypto = require('crypto');
// Compressed P-256 point -> SPKI DER -> KeyObject.
function p256KeyFromCompressedHex(hex) {
const point = Buffer.from(hex, 'hex');
if (point.length !== 33) throw new Error('expected 33-byte compressed P-256 key');
const spkiPrefix = Buffer.from(
'3039301306072a8648ce3d020106082a8648ce3d030107032200', // SEQ{ AlgId(ecPublicKey, prime256v1), BIT STRING(len 34) }
'hex'
);
return crypto.createPublicKey({
key: Buffer.concat([spkiPrefix, point]),
format: 'der',
type: 'spki',
});
}
function verifyRequest(req, rawBody, isRegisteredKey) {
const pubKeyHex = req.headers['x-acurast-pubkey'];
const timestamp = req.headers['x-acurast-timestamp'];
const signature = Buffer.from(req.headers['x-acurast-signature'], 'hex');
// 1. Only accept keys that belong to an active deployment of yours (see below).
if (!isRegisteredKey(pubKeyHex)) return false;
// 2. Reject stale requests.
if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000) return false;
// 3. Rebuild the exact message the processor signed and verify.
const message = ['POST', req.path, timestamp, rawBody].join('\n');
return crypto.verify(
'sha256',
Buffer.from(message),
{ key: p256KeyFromCompressedHex(pubKeyHex), dsaEncoding: 'ieee-p1363' },
signature
);
}
module.exports = { verifyRequest };
crypto.verify('sha256', message, ...) hashes message with SHA-256 and checks
the signature over that digest. That is why the processor side signs
sha256(message) and not message. If you instead see verification failures
with a correct key, this mismatch is the first thing to check.
3. Know which keys to trust
A valid signature only proves that some processor with some deployment key signed the request. To know it was your deployment, the server needs the set of public keys assigned to it. Two ways to get them:
- Read the match record on-chain. When a processor is matched to your
deployment, the assignment stored in the
acurastMarketplacepallet includespubKeys, with the P-256 key underSECP256r1. It equalsgetPublicKeys().p256byte for byte. Use the Acurast SDK or a Polkadot.js connection to read it. - Register on first contact. Have the deployment call a registration
endpoint on start with its
getPublicKeys(), and have the server cross-check the key against the on-chain match record before storing it.
When a deployment ends and is cleaned up, its match records (including
pubKeys) are removed from chain state. Harvest and persist the keys you need
while the deployment is active. Do not rely on reading them back later.
Using the other curves
The same flow works with secp256k1 (public key getPublicKeys().secp256k1,
raw low-s r || s output, no recovery byte) and with ed25519. Ed25519
differs in one way: it hashes internally, so pass the full message to
_STD_.signers.ed25519.sign(), not a digest, and verify with
crypto.verify(null, message, key, signature) on the server.
In the Cargo runtime the
equivalent call is the signer_sign RPC method with curve: "p256". It has the
same digest-in, r || s-out behavior.