Signatures and passkeys
A receipt carries two signatures: the company's, attesting to the offer it made, and the citizen's, attesting to the answer they gave. They use different suites because the two parties are different kinds of signer.
The company signature: eddsa-jcs-2022
The company signs the offer with eddsa-jcs-2022: an Ed25519 detached
signature over the JCS-canonical bytes of the offer, published as a W3C Data
Integrity proof.
companySig = Ed25519-sign( JCS(offer), companySecretKey )
The signature is surfaced as a DataIntegrityProof:
{
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"created": "2026-06-26T15:00:00Z",
"verificationMethod": "did:web:agreely.ca:c:acme#kms-1",
"proofPurpose": "assertionMethod",
"proofValue": "z3Ge8...base58btc..."
}
proofValue is the 64-byte signature as a z-prefixed base58btc multibase
string.
The company DID: did:web, Agreely-hosted
A company signs as did:web:agreely.ca:c:{slug}. Agreely hosts the document at
https://agreely.ca/c/{slug}/did.json. The document is bound to the company's
verified real domain via alsoKnownAs, so a verifier sees both the
friction-light Agreely-hosted identifier and the proven domain. A company with no
verified domain has no resolvable document, which is the binding requirement.
did:web is the right choice here precisely because a company is a domain; the
later upgrade simply swaps the host to the company's own domain.
The key is published as a Multikey publicKeyMultibase, so a verifier resolves
it from the DID document and checks companySig with no trust in Agreely. See
DID documents.
The citizen signature: WebAuthn passkeys
Citizens never hold a private key file. They sign with a WebAuthn passkey: the platform authenticator (Face ID, Touch ID, a security key) holds the secret, and the receipt carries the resulting assertion. The library enforces, for free, that the assertion's challenge equals the one Agreely issued and that the signature verifies against the stored COSE public key.
There are two different challenges, and the difference matters.
Onboarding: a random challenge
Registration and login use a random 32-byte challenge, the standard WebAuthn flow. This proves the person controls the authenticator. Registration requires user verification and a resident (discoverable) key, and accepts ES256, EdDSA, or RS256 credentials.
Approval: a keccak challenge bound to the consent
When the person approves a consent, the challenge is not random. It is a keccak256 hash that binds the signature to exactly this offer and its Merkle root:
challenge = keccak256( JCS(context) || nonce32 )
where the binding context is frozen as:
{
"issuer": "did:web:agreely.ca:c:acme",
"subject": "did:agreely:citizen:9F8K...",
"requestId": "0x...",
"action": "grant",
"grantedAt": "2026-06-26T15:04:05Z",
"merkleRoot": "0x..."
}
The assertion that lands in the receipt is a WebAuthnAssertion proof carrying
the verificationMethod (the DID's #passkey-n fragment), the challenge, and
the raw authenticatorData, clientDataJSON, and signature. A verifier
resolves the COSE key that was valid at grantedAt and
checks the assertion against it.
Next
- On-chain registry: anchoring the root and revocations.
- The receipt VC: both proofs in one document.