Signatures
A digital signature proves that the holder of a private key approved an exact message. Certichain uses signatures for far more than transactions: the issuer's signature is the heart of a credential, the holder's signature binds a disclosure to one verifier, the server signs API answers, and several keys are derived from wallet signatures. This page explains the schemes and lists every structure the code signs.
ECDSA on secp256k1
Ethereum accounts sign with ECDSA over the secp256k1 curve. A signature is three values, r, s and a recovery id v, usually serialised as 65 bytes (0x and 130 hex characters). From a message hash and a signature anyone can recover the signer's address, so the verifier needs no separate public key.
Wallets differ in how they write v (0 or 1, or 27 or 28) and some use a 64-byte compact form. Where Certichain derives a key from a signature, it first rewrites the signature into one canonical byte string (canonicalSignature in src/lib/doc-crypto/identity-key.ts), so the same approval always gives the same key.
EIP-191: signing plain messages
A wallet never signs raw bytes that could be a transaction. EIP-191 (personal_sign) prefixes a human-readable message with "\x19Ethereum Signed Message:\n" and its length before hashing. Certichain uses this only for Sign-In with Ethereum, described below.
EIP-712: signing typed data
EIP-712 lets a wallet sign structured data and show it to the user as named fields instead of an opaque hash. The digest is:
keccak256(0x19 0x01 || domainSeparator || hashStruct(message))
- The domain separator hashes a small struct describing where the signature is meant to be used:
name,version, and optionallychainIdandverifyingContract. A signature for one domain is useless in another. hashStructhashes the type string (for exampleCredential(address issuer,bytes32 subjectCommitment,...)) together with the encoded field values, so a signature for one type cannot be passed off as another.
The ethers TypedDataEncoder computes the digest in the browser and in scripts; OpenZeppelin's EIP712 contract computes the same digest on chain with _hashTypedDataV4. A shared fixture keeps the two in agreement (src/lib/eip712.test.ts).
Every typed-data structure Certichain signs
| Primary type | Domain (name, version, binding) | Signed by | What the signature does | Code |
|---|---|---|---|---|
Credential | Certichain, 1, chain id and the CredentialRegistry address | Issuer wallet | Authorises one credential. The digest is the credential id. Checked on chain at issuance and offline from the package. | src/lib/eip712.ts, blockchain/contracts/v03/CredentialRegistry.sol |
Invitation | Certichain, 1, chain id and the HolderRegistry address | Issuer wallet | Invites one named wallet to bind to one enrolment, until an expiry and for one binding epoch. The digest is the invitation id. | src/lib/invitation.ts, blockchain/contracts/v03/HolderRegistry.sol |
StudentSalt | Certichain Student Salt, 1, no chain | Issuer wallet (EOA only) | Its keccak-256 is the stable student salt behind the enrolment commitment. Never sent anywhere. | src/lib/commitments.ts |
EncryptionKey | Certichain keys, 1, chain id | Any wallet that opens documents (issuer or recipient) | Seed for the X25519 document encryption key. Never sent anywhere. | src/lib/doc-crypto/identity-key.ts |
OpenBadgeKey | Certichain keys, 1, chain id | Issuer wallet | Seed for the Ed25519 key behind the standard Open Badge proof. Never sent anywhere. | src/lib/vc/openbadges/standard/key.ts |
OpenBadgeKeyBinding | Certichain keys, 1, chain id | Issuer wallet | States that a given Ed25519 public key and key version belong to this issuer for this registry. Carried in the badge. | src/lib/vc/openbadges/standard/key.ts |
AccessIdentity | Certichain keys, 1, chain id | Requester wallet | Seed for a Semaphore identity and an X25519 key used with the access gate. Never sent anywhere. | src/lib/zk/access-identity.ts |
AttributeSet | The credential's own domain | Issuer wallet | Signs the root of the selective disclosure tree for one credential and tree version. | src/lib/disclosure/attestation.ts |
DisclosureRequest | The credential's own domain | Holder wallet (the bound wallet) | Binds a disclosure (which fields, as a bit mask) to the verifier's random challenge. | src/lib/disclosure/disclosure.ts |
StatusList | Certichain, 1, chain id and the registry address | Status list publisher key (server) | Signs one Bitstring Status List: registry, issuer, purpose, list number, hash of the encoded list, validity window. | src/lib/vc/status/list-credential.ts |
ApiResponse | Certichain API, 1, chain id | API signer key (server) | Signs a verification API answer: API version, chain, registry, body hash, time window, the caller's nonce. | src/lib/api/envelope.ts |
The field lists are short enough to quote. Two examples:
export const CREDENTIAL_TYPES = {
Credential: [
{ name: "issuer", type: "address" },
{ name: "subjectCommitment", type: "bytes32" },
{ name: "documentHash", type: "bytes32" },
{ name: "metadataHash", type: "bytes32" },
{ name: "recordType", type: "uint32" },
{ name: "issuedAt", type: "uint64" },
{ name: "validUntil", type: "uint64" },
{ name: "nonce", type: "uint256" },
],
} as const;
export const INVITATION_TYPES = {
Invitation: [
{ name: "issuer", type: "address" },
{ name: "enrolmentCommitment", type: "bytes32" },
{ name: "wallet", type: "address" },
{ name: "expiry", type: "uint64" },
{ name: "epoch", type: "uint64" },
{ name: "nonce", type: "uint256" },
],
} as const;
EncryptionKey, OpenBadgeKey and AccessIdentity have the same two fields (owner, keyVersion) and the same domain, but different primary type names. The type name is part of the digest, so each produces a different signature and therefore a different key. One signature can never unlock another purpose.
Credential nonces and replay
The nonce in a Credential is a random 256-bit number chosen in the browser. The CredentialRegistry records each (issuer, nonce) pair it has accepted and refuses a second use with NonceAlreadyUsed. Together with the domain (chain id and registry address) this means a signed credential can be submitted once, to one registry, on one chain. The signature itself is emitted in the CredentialSigned event so anyone can re-check it later.
The issuance signature, end to end
ERC-1271: signatures from contract wallets
A contract account has no private key, so it cannot produce an ECDSA signature of its own. ERC-1271 defines a function a wallet contract implements instead:
function isValidSignature(bytes32 hash, bytes memory signature) external view returns (bytes4 magicValue);
It returns 0x1626ba7e when the wallet approves hash. A Safe, for example, approves when enough of its owners have signed.
OpenZeppelin's SignatureChecker.isValidSignatureNow(signer, hash, signature) tries ECDSA recovery first and, if the signer is a contract, calls isValidSignature on it. Because the answer comes from the contract's current state, only the chain can confirm an ERC-1271 signature.
In Certichain
- The
CredentialRegistryand theHolderRegistrycheck signatures withSignatureChecker, so a contract wallet can be an issuer on chain and can sign invitations. - The offline package check reports
"needs-contract-check"when ECDSA recovery does not produce the issuer, because the issuer may be a contract wallet (verifyPackageOfflineinsrc/lib/credential-package.ts). The verify page then relies on the chain's answer. - The disclosure check accepts an ERC-1271 approval from the bound holder wallet (
DisclosureService.holderStateinsrc/services/domain/disclosure-service.ts). - The site cannot yet derive keys or student salts for a contract-wallet issuer, because those need deterministic signatures. It refuses with "This issuer account is a contract wallet, which cannot derive student identifiers yet. Use a plain wallet account to invite students." and, for documents, "This account is a contract wallet, which cannot protect documents yet. Use a plain wallet account." See Known limits.
Deterministic signatures and signing twice
ECDSA needs a secret random number k for every signature. If k were ever reused or guessable, the private key would leak. RFC 6979 removes the need for randomness by deriving k from the private key and the message hash. A side effect is that the same key signing the same message always produces the same signature. Standard EOA wallets such as MetaMask sign this way.
Certichain relies on that property to avoid storing keys: it asks the wallet to sign fixed typed data, and feeds the signature into HKDF to derive a key. Signing again later recovers the same key. If a wallet signed non-deterministically, a key derived today could never be recovered, and documents encrypted to it would be lost.
The app therefore checks determinism before deriving a long-lived key:
const sign = () => this.identity.signTypedData({ ...domain }, types as never, { ...value });
const [first, second] = [await sign(), await sign()];
if (first.toLowerCase() !== second.toLowerCase()) throw new NonRepeatableSignatureError();
if (getAddress(verifyTypedData(domain, types, value, first)) !== getAddress(address)) throw new SignerMismatchError();
| Derived value | Signed twice | Where |
|---|---|---|
Document encryption key (EncryptionKey) | Yes | src/services/documents/wallet-key-session.ts |
Open Badge signing key (OpenBadgeKey) | Yes | src/services/domain/badge-service.ts |
Access identity (AccessIdentity) | Yes | src/services/domain/access-requester-service.ts |
Student salt (StudentSalt) | No: signed once and checked against the issuer address | src/services/domain/enrolment-service.ts |
So a user sees two identical signature prompts the first time a page needs one of the first three keys in a session. The keys are then held in memory for the page session and dropped when the wallet account or chain changes. A wallet that fails the check gets a plain explanation, for example "This wallet does not sign the same message the same way twice, so it cannot protect documents. Use a standard wallet account."
Sign-In with Ethereum (EIP-4361)
The upload and document routes must know that a request comes from a registered issuer. Sign-In with Ethereum (SIWE) is a standard plain-text message format, signed with EIP-191, that proves control of an address to a website.
The client builds the message in buildSignInMessage (src/services/upload.ts) with the site host as domain, the statement Sign in to Certichain to upload records, the chain id, a server nonce and an expiry 5 minutes ahead.
The server side is verifySignIn in src/lib/server/sign-in.ts. It returns no address if any check fails:
- the chain id must match the deployment;
- the domain must be in
SIWE_ALLOWED_DOMAINS(or the request's own host when that is not set); - the statement must be exactly the expected one and an expiry must be present;
- the nonce must exist in the store and is deleted as it is read, so a signed message works once;
- the signature must verify for the stated address at the current time. The
siwelibrary is called without a chain connection, so this is an ECDSA check.
On success the route checks the issuer registration and sets a session cookie: an HS256 JWT signed with SESSION_SECRET (at least 32 characters), named certichain_session, valid for 30 minutes, with Path=/api; HttpOnly; Secure; SameSite=Strict. Every issuer-only route re-checks the registration when the last check is more than 5 minutes old (requireIssuerSession in src/lib/server/issuer-session.ts). Details are in Sign-in and sessions.
Ed25519
Ed25519 is a signature scheme on Curve25519, standardised in RFC 8032. Its signatures are deterministic by design and its public keys are 32 bytes. It is the key type behind the W3C eddsa-rdfc-2022 Data Integrity cryptosuite, which general-purpose verifiers support.
In Certichain
Open Badges carry a second proof that any Data Integrity verifier can check without knowing about Ethereum. The issuer's wallet signs OpenBadgeKey twice, the signature goes through HKDF-SHA256 with the salt certichain.badge-key.v1 to a 32-byte seed, and the seed becomes an Ed25519 key pair identified as a did:key (deriveBadgeKey in src/lib/vc/openbadges/standard/key.ts). A separate OpenBadgeKeyBinding signature from the issuer wallet ties that public key to the issuer and registry, so Certichain's own checks can confirm the Ed25519 key belongs to the registered issuer. See Standards.
Source files
src/lib/eip712.ts,src/lib/invitation.ts,src/lib/commitments.tssrc/lib/credential-package.tssrc/lib/doc-crypto/identity-key.ts,src/services/documents/wallet-key-session.tssrc/lib/vc/openbadges/standard/key.ts,src/services/domain/badge-service.tssrc/lib/zk/access-identity.ts,src/services/domain/access-requester-service.tssrc/lib/disclosure/attestation.ts,src/lib/disclosure/disclosure.ts,src/services/domain/disclosure-service.tssrc/lib/vc/status/list-credential.ts,src/lib/api/envelope.tssrc/services/upload.ts,src/lib/server/sign-in.ts,src/lib/server/session.ts,src/lib/server/issuer-session.ts,src/app/api/auth/verify/route.tsblockchain/contracts/v03/CredentialRegistry.sol,blockchain/contracts/v03/HolderRegistry.sol
Tests
src/lib/eip712.test.ts,src/lib/invitation.test.ts,src/lib/credential-package.test.tssrc/lib/doc-crypto/identity-key.test.ts,src/services/domain/document-service.test.tssrc/lib/vc/openbadges/standard/key.test.ts,src/services/domain/badge-service.test.tssrc/lib/zk/access-identity.test.ts,src/services/domain/access-requester-service.test.tssrc/lib/disclosure/attestation.test.ts,src/lib/disclosure/disclosure.test.ts,src/services/domain/disclosure-service.test.tssrc/lib/vc/status/list-credential.test.ts,src/lib/api/envelope.test.tssrc/services/upload.test.ts,src/app/api/upload/route.test.ts(sign-in message, session and issuer checks)blockchain/test/v03/CredentialIssuance.test.ts,blockchain/test/v03/HolderRegistry.test.ts,blockchain/test/v03/SafeGovernance.test.ts