Passkey Evidence Specification¶
Version: 1.0.0
Status: Draft
Evidence type: HavenWebAuthnEvidence (version 1)
Implementations: harbour.evidence / harbour.merkle (Python),
evidence.ts / merkle.ts (TypeScript)
1. Overview¶
A Harbour credential carries two signatures that mean different things:
| Signature | Made by | Proves |
|---|---|---|
| Proof: the issuer JWT signature | The issuer's signing key, held by the signing service | The issuer published this payload |
| Evidence: a WebAuthn assertion inside the payload | The passkey of the person who approved it | A named person decided this payload should exist |
The signing service only performs cryptography; it never decides. Evidence makes that checkable: a stolen or misused signing key can produce a validly signed credential, but not a passkey assertion from an approver. A verifier follows the evidence from approver to approver up to a passkey that the trust anchor lists in its own DID document.
This document specifies the evidence object, how to verify a chain of it, and how one passkey assertion can approve a whole batch of credentials (§5). It is written for independent verifiers: everything here can be implemented without the Harbour libraries.
Conventions: all binary values are base64url without padding. All hashes are SHA-256. JCS is the RFC 8785 JSON Canonicalization Scheme. WebAuthn refers to W3C Web Authentication Level 3.
2. Identifiers and documents¶
A brand tenant publishes did:web documents under one host:
| Party | DID | Issues |
|---|---|---|
| Service | did:web:<host> |
Email credentials; operates the revocation registry |
| Trust anchor | did:web:<host>:participants:<slug> |
Organisation credentials |
| Organisation | did:web:<host>:participants:<slug> |
Its members' credentials |
Every one of these documents lists the tenant's signing keys under
assertionMethod with ids <did>#key-<RFC 7638 thumbprint>. A person is
identified by sub = urn:uuid:<uuid>, which is never resolved.
The trust anchor document additionally carries:
- one service of type
HavenWebAuthnRelyingPartywhoseserviceEndpointis{ "rpId": "<WebAuthn RP ID>", "origins": ["https://..."] }; - the passkeys of the trust anchor admins as verification methods with id
<trust anchor DID>#passkey-<base64url(SHA-256(rawId))>and apublicKeyJwk, referenced fromauthentication.
Credentials are SD-JWT VCs ([SD-JWT-VC]) with the issuer JWT header
{ "alg": "ES256", "typ": "dc+sd-jwt", "kid": "<iss>#key-..." }. Member
credentials carry memberOf, role (admin or member) and
authenticators (the person's passkeys, [{ "credentialId", "jwk" }]) in the
clear. memberOf is the organisation DID, or an array whose first element is
the organisation DID.
| Family | vct values (default profile) |
|---|---|
| Member | haven:MemberCredential, simpulseid:UserCredential, simpulseid:AdministratorCredential |
| Organisation | haven:OrganisationCredential, simpulseid:ParticipantCredential, simpulseid:AscsBaseMembershipCredential, simpulseid:AscsEnvitedMembershipCredential |
The implementations take these lists as parameters and use the ones above by default.
3. The payload digest¶
The digest of an object p is
For a credential, p is the payload of the issuer JWT exactly as signed,
including _sd, _sd_alg, cnf, credentialStatus, iat and exp.
Disclosures do not enter the digest, so it can be recomputed from the issuer
JWT alone, whatever the holder chose to disclose. The issuer therefore fixes
the disclosure salts before asking for approval (build_sd_jwt_payload /
buildSdJwtPayload), and signs that same payload afterwards.
For an action that is not an issuance, p is an instruction object:
{
"@context": "https://schema.reachhaven.com/instructions/v1",
"type": "haven:RevokeCredential",
"tenant": "<tenant id>",
"credential": "urn:uuid:<jti of the credential>",
"entry": "0x<the credential's CRSetEntry id>",
"actor": "urn:uuid:<person acting>",
"issuedAt": 1800000000,
"nonce": "<random>"
}
4. Evidence¶
4.1 The evidence object¶
evidence is an array holding exactly one object:
{
"type": "HavenWebAuthnEvidence",
"version": 1,
"kind": "approval",
"credentialId": "<WebAuthn rawId>",
"authenticatorData": "<raw authenticatorData>",
"clientDataJSON": "<raw clientDataJSON>",
"signature": "<raw assertion signature>",
"approver": { "sub": "urn:uuid:...", "memberOf": "<org or trust anchor DID>", "role": "admin" },
"approverCredential": "<issuer JWT of the approver's member credential>~",
"merklePath": [{ "hash": "<32 bytes>", "position": "left" }]
}
kindisapproval, orendorsementwhen a person's own existing passkey approves a reissue of their own credential that only changesauthenticators. For an endorsementapprover.roleis the person's own role.approverCredentialis omitted when the approver acts as a trust anchor admin through a passkey listed in the trust anchor document. A person whose passkey is listed there and who also administers another organisation includes the member credential of that organisation to act for it.merklePathis present only when one assertion approved several payloads (§5). Absent,nulland[]all mean a batch of one.- The evidence is not covered by the digest; it is covered by the issuer signature.
- Verifiers ignore members they do not know.
The one exception is the trust anchor's own organisation credential (iss and
sub both the trust anchor DID). It has evidence: [] and is the root of every
chain.
4.2 The challenge¶
The WebAuthn challenge the approver signed is
where fold is defined in §5.2 and returns its first argument unchanged when
there is no path. clientDataJSON.challenge is the base64url form of these 32
bytes, so for single-payload evidence it equals digest(p).
4.3 Verifying an evidence chain¶
Inputs: the issuer JWT of the credential, the trust anchor DID the verifier
trusts, a DID resolver, and the current time. Start at depth 0 with the
credential as P.
- Verify the issuer JWT of
P:typMUST bedc+sd-jwt;kid(made absolute againstissif it starts with#) MUST be a DID URL ofiss; verify the signature with the key theissdocument lists underassertionMethodfor thatkid. Requireiss,sub, integeriatandexp, anevidencearray,iatat most 60 s in the future, and a member or organisationvct. An integer here is a JSON number without a fractional part whose magnitude is at most 2^53 − 1, so that every JSON parser reads the same value. A payload that is not RFC 8259 JSON (NaN,Infinity) ismalformed-credential. IfPis the trust anchor's own organisation credential withevidence: [], the chain ends successfully. Otherwise require exactly one evidence objectE. - Decode
E.clientDataJSONas UTF-8 JSON. Requiretypeto bewebauthn.get,challengeto equalchallenge(P, E)(§4.2), andcrossOriginnot to betrue. - Resolve the trust anchor document and read its
HavenWebAuthnRelyingPartyservice. Require the client dataoriginto be one of itsorigins. - Decode
E.authenticatorData. Require at least 37 bytes, bytes 0 to 31 equal to SHA-256 of the UTF-8rpId, and the flags byte 32 to have both user presence (0x01) and user verification (0x04) set. The signature counter is not checked; verification is stateless. - Find the approver's key.
- If
E.kindisapproval,E.approverCredentialis absent and the trust anchor document references<ta>#passkey-<base64url(SHA-256(rawId))>fromauthentication, that method'spublicKeyJwkis the key and the approver is a trust anchor admin.E.approver.memberOfMUST be the trust anchor DID andE.approver.roleMUST beadmin. - Otherwise
E.approverCredentialis required, even when the passkey is also listed in the trust anchor document: the approver then acts for the organisation of their member credential. Verify it as in step 1 and call its payloadAC. Require a membervctandAC.iat <= P.iat <= AC.exp(for an instruction,issuedAtin place ofP.iat). The entry ofAC.authenticatorswhosecredentialIdequalsE.credentialIdgives the key.E.approverMUST equalAC.sub, the organisation ofACandAC.role. The current revocation status ofACis deliberately not checked: a later revocation of the approver does not undo what they approved. - Verify the signature over
authenticatorData || SHA-256(clientDataJSON), both as raw bytes. For an EC P-256 key the WebAuthn signature is ASN.1 DER (convert it tor || sfor WebCrypto; tolerate redundant leading zeros, reject trailing bytes). For an Ed25519 key the signature is raw. Any other key type fails. - Check authority. "Trust anchor admin" means step 5.1, or an
ACwhose organisation is the trust anchor and whose role isadmin. - Organisation credential:
P.issMUST be the trust anchor DID and the approver a trust anchor admin. Endorsements are not allowed. - Member credential of organisation
org(the firstmemberOfentry):P.issMUST equalorg. The approver MUST be an admin. Whenorgis the trust anchor itself, only a trust anchor admin may approve. Otherwise the approver MUST be an admin whose organisation isorg, or a trust anchor admin whenP.roleisadmin. A trust anchor admin approving amembercredential of another organisation fails withapprover-wrong-organisation. The trust anchor seats an organisation's admins; the organisation's own admins approve its members. A verifier cannot tell a first admin from a later one, since the trust anchor issues the organisation credential and could reissue it, so limiting the trust anchor to the first admin is the issuer's policy. - Endorsement: the approver MUST come from step 5.2 with
AC.sub = P.sub, the same organisation asPandAC.role = P.role. Compare the complete issuer-signed payloads ofACandP: onlyauthenticators,iat,jti, andevidenceMAY differ. These permit passkey updates and reissue metadata; every other claim MUST remain identical, includingexp,vct,cnf,credentialStatus, disclosure hashes, and all membership entries. Added or removed claims outside this allowlist also fail withendorsement-payload-mismatch. Preserve disclosure salts when reissuing. - If step 5.2 was used, repeat from step 1 with
ACasPat depth + 1. Fail when the depth would exceed 8. The chain succeeds when it ends in a trust anchor passkey (step 5.1).
The result is { ok: true, approvers } with one entry
{ sub, memberOf, via: "trust-anchor" | "credential" } per level, nearest
first, or { ok: false, reason, depth }. Chain verification does not check the
expiry or revocation of P itself.
For an instruction, run steps 2 to 6 against challenge(instruction, E),
require E.kind = approval and E.approver.sub = actor, and verify the
approver credential's own chain from step 1. Which approvers may revoke which
credential is the caller's policy.
Before signing, an issuer runs the same checks on the unsigned payload
with evidence: [E] added (verify_evidence_for_payload /
verifyEvidenceForPayload), so that a signature always follows a human
decision that was already checked.
4.4 Failure reasons¶
Implementations report the first failing check with one of these reasons, so results compare across runtimes:
| Step | Reasons |
|---|---|
| 1 | malformed-credential, unexpected-typ, unsupported-alg, did-resolution-failed, issuer-key-not-found, bad-issuer-signature, issued-in-future, unknown-vct, evidence-count, malformed-evidence, malformed-instruction |
| 2 | client-data-invalid, digest-mismatch, cross-origin |
| 3 | relying-party-missing, origin-not-allowed |
| 4 | authenticator-data-invalid, rp-id-hash-mismatch, user-not-present, user-not-verified |
| 5 | approver-credential-missing, approver-credential-not-member, approver-credential-out-of-window, approver-key-not-found, approver-mismatch |
| 6 | bad-webauthn-signature, unsupported-alg |
| 7 | approver-not-admin, approver-wrong-organisation, approver-not-trust-anchor, issuer-not-organisation, issuer-not-trust-anchor, endorsement-subject-mismatch, endorsement-payload-mismatch |
| 8 | depth-exceeded |
depth is 0 for the credential itself and n for the n-th approver
credential down the chain.
5. Batch approval¶
An admin who approves twenty new members, or offboards a team, should not need twenty passkey taps. A batch lets one assertion approve N payloads (credentials, instructions, or both) while every credential stays verifiable on its own, without the rest of the batch.
5.1 Construction¶
Let L_i = SHA-256(UTF-8(JCS(p_i without evidence/proof))) be the raw digest
bytes of payload i, in an order the issuer chooses.
- Leaf:
L_iitself. - Node:
H(left, right) = SHA-256(0x01 || left || right). - Tree: level 0 is the leaves. Each next level hashes pairs
(2k, 2k+1)left to right; a lone last node is promoted unchanged, never duplicated. The single node of the last level is the root. - Challenge: the root. The approver's passkey signs it as the WebAuthn challenge.
A batch of one has root L_0, so its challenge is digest(p_0): single-payload
evidence is exactly a batch of size 1, and every verifier that implements §5
also verifies §4 evidence unchanged.
5.2 The path and the fold¶
Each credential's evidence carries the shared assertion values and its own
merklePath: the sibling digests from its leaf up to the root, each
{ "hash": "<base64url, 32 bytes>", "position": "left" | "right" }, where
position is the side the sibling occupies. A level at which the node was
promoted contributes no element. No leaf index is carried.
fold(leaf, path):
acc = leaf
for {hash, position} in path:
acc = H(hash, acc) if position == "left" else H(acc, hash)
return acc
A path MUST be an array of at most 32 elements, each with a 32-byte hash and
a position of left or right; anything else fails with
malformed-evidence. Verification otherwise proceeds exactly as in §4.3, with
challenge(P, E) = fold(L_P, E.merklePath) in step 2.
5.3 Security notes¶
- Domain separation. Leaves carry no prefix so that size-1 batches stay
byte-identical to single-payload evidence. A leaf preimage is JCS JSON text,
which always starts with
{(0x7B); a node preimage is 65 bytes starting with0x01. No input is both, so a node cannot be passed off as a payload digest without a SHA-256 collision. Verifiers always recompute the leaf from a payload and never accept a bare digest. - No duplication. Promoting the odd node instead of duplicating it closes CVE-2012-2459, where two different batches share a root.
- Independence. A credential's path reveals only sibling hashes. Payloads
contain random identifiers (
jti, CRSet entry ids, disclosure salts), so the hashes reveal nothing about the other members of the batch. - What the person sees. The authenticator shows no payload, only the issuer's approval screen does; this holds for single approvals too. For a batch, that screen MUST list every payload in the batch and their number.
- Authority is per payload. One assertion proves one decision by one
person, but step 7 runs for each credential separately. A batch cannot give
an approver authority they do not have for any individual member. Each
evidence object picks its approver form on its own (§4.3 step 5), so a
person who is a trust anchor admin and an admin of another organisation can
approve trust anchor items without
approverCredentialand that organisation's items with it, under one assertion. - Approver window.
AC.iat <= P.iat <= AC.expis checked per credential, so all payloads of a batch should share the approval time asiat.
5.4 Issuer procedure¶
- Prepare every payload, fixing disclosure salts (
build_sd_jwt_payload). - Compute the challenge and paths (
build_batch/buildBatch), show the approval screen, and request one WebAuthn assertion over the challenge. - For each payload, build the evidence with its path
(
build_webauthn_evidence/buildWebAuthnEvidence), check it withverify_evidence_for_payload, then add it asevidence: [E]and sign.
6. Test vectors¶
tests/fixtures/evidence/evidence-vectors.json: DID documents, credentials and instructions with their expected results, covering single approvals, endorsements, revocation instructions, batches of 3 and 5, a mixed credential and instruction batch, and negative cases (tampered payloads and signatures, wrong approvers, origins and flags, malformed paths), with at least one case for every failure reason in §4.4. A case may carry its owndidDocumentsormaxDepth. Both runtimes must return the sameok,approvers,reasonanddepthfor every case.tests/fixtures/evidence/merkle-vectors.json: known answers for the tree, batch sizes 1 to 9.
Both files are written by tests/fixtures/evidence/gen_evidence_vectors.py.
7. Scope¶
Not covered here yet: presentation evidence (a passkey assertion carried in the key binding JWT), the revocation snapshot chain, and the trust anchor key log.
Verifiers that implement only §4 ignore merklePath and so reject batch
evidence with digest-mismatch, which fails safe.