fix(connect): sync protocol fixture consumers (#7167)

* fix(connect): sync protocol fixture consumers

* fix(connect): preserve enrollment validation order

* fix(connect): satisfy base64 length lint

* fix(connect): restore signature validation order

* fix(connect): preserve signature precedence across chain parsing

* fix(connect): preserve signature error classification

* test(ci): provide log path in workflow harness
This commit is contained in:
Zhengchao An
2026-09-06 10:12:51 +08:00
committed by GitHub
parent 07833379b4
commit 5dca076efe
13 changed files with 754 additions and 58 deletions
@@ -1,5 +1,6 @@
5133761d19d6a64c18b6b5f871d646f6a2da4ceccc998d3cf7e22f692ca2d925 accept-vectors.json
c7da10d173e7fafa112743d9a41e2bc94df58bf88a0542d80350b74da8f382a5 error-codes.json
ce9f5b66c629b31e14c3986937565d3cb253d37bb7d546102cc73614b1de85ee boundary-vectors.json
dbb1ad902d24a4c4508dba6d63244dd567bfc58e778b76a61941605ac43a29dc error-codes.json
e98cfbedfb385defdaa9d001c85fdebcf9df2b4d054930951ff59dfa1385e52f reject-vectors.json
69d43c8266d7bb29b4df7105c49250293943583f2202b93d922d9a924fca0c09 trust-chain.json
e60cfca04bf0ce2f69495c49a95e4cc42e92e8114f6ad43449084527b06a0939 trust-model.json
7116f55de42a438bf5de8f6cc1e3c636b216a4db5a37f4caf49fe07d226498b1 trust-model.json
@@ -0,0 +1,181 @@
{
"protocolVersion": "v1",
"fixtureSet": "offline-enrollment",
"fixture": "boundary-vectors",
"description": "Frozen challenge boundary and decision vectors for failures that cannot be added as newly signed golden documents because no fixture private key exists. Mutations start from the accepted pinned-root challenge and are applied without re-signing; every targeted rule runs before the signature invalidated by that mutation.",
"sourceVector": "challenge signed by a chained signing key under the pinned root",
"preparseMutations": [
{
"name": "challenge envelope is not JSON",
"scope": "serializedEnvelope",
"value": "{",
"expectedReason": "DOCUMENT_MALFORMED"
},
{
"name": "challenge bytes are non-canonical padded base64",
"scope": "envelopeBytes",
"value": "QR==",
"expectedReason": "DOCUMENT_MALFORMED"
},
{
"name": "challenge bytes decode to a JSON scalar",
"scope": "envelopeBytes",
"value": "bnVsbA==",
"expectedReason": "DOCUMENT_MALFORMED"
},
{
"name": "challenge is missing trustChain",
"scope": "challenge",
"operation": "remove",
"field": "trustChain",
"expectedReason": "DOCUMENT_MALFORMED"
},
{
"name": "challenge trustChain is a JSON object",
"scope": "challengeChain",
"operation": "objectWithFirst",
"expectedReason": "DOCUMENT_MALFORMED"
},
{
"name": "challenge issuedAt is not a real calendar instant",
"scope": "challenge",
"operation": "replace",
"field": "issuedAt",
"value": "2026-02-31T00:00:00Z",
"expectedReason": "DOCUMENT_MALFORMED"
},
{
"name": "first trust link issuerKeyId is malformed",
"scope": "trustLink",
"index": 0,
"operation": "replace",
"field": "issuerKeyId",
"value": "not-a-key-id",
"expectedReason": "DOCUMENT_MALFORMED"
}
],
"verificationMutations": [
{
"name": "challenge signature algorithm is not ES256",
"scope": "envelopeSignature",
"operation": "replace",
"field": "algorithm",
"value": "ES384",
"expectedReason": "SIGNATURE_MALFORMED"
},
{
"name": "challenge signature keyId is malformed",
"scope": "envelopeSignature",
"operation": "replace",
"field": "keyId",
"value": "not-a-key-id",
"expectedReason": "SIGNATURE_MALFORMED"
},
{
"name": "challenge signature keyId names another well-formed key",
"scope": "envelopeSignature",
"operation": "replace",
"field": "keyId",
"value": "f6fbe050defded18b50477ace38c9515fb61b8157e57b2f0e7e8ca69c862b6ca",
"expectedReason": "SIGNATURE_INVALID"
},
{
"name": "pinned-root challenge carries only one trust link",
"scope": "challengeChain",
"operation": "keepFirst",
"expectedReason": "TRUST_CHAIN_INVALID"
},
{
"name": "intermediate link formatVersion is unsupported",
"scope": "trustLink",
"index": 0,
"operation": "replace",
"field": "formatVersion",
"value": "rustfs.connect.offline.trustLink/2",
"expectedReason": "TRUST_CHAIN_INVALID"
},
{
"name": "intermediate link protocolVersion is unsupported",
"scope": "trustLink",
"index": 0,
"operation": "replace",
"field": "protocolVersion",
"value": "v2",
"expectedReason": "TRUST_CHAIN_INVALID"
},
{
"name": "intermediate link signature algorithm is not ES256",
"scope": "trustLinkSignature",
"index": 0,
"operation": "replace",
"field": "algorithm",
"value": "ES384",
"expectedReason": "TRUST_CHAIN_INVALID"
},
{
"name": "intermediate link omits serial",
"scope": "trustLink",
"index": 0,
"operation": "remove",
"field": "serial",
"expectedReason": "TRUST_CHAIN_INVALID"
},
{
"name": "first trust link carries signing role",
"scope": "trustLink",
"index": 0,
"operation": "replace",
"field": "role",
"value": "signing",
"expectedReason": "TRUST_CHAIN_INVALID"
}
],
"linkValidityPolicyVectors": [
{
"name": "intermediate link at its maximum validity",
"role": "intermediate",
"notBefore": "2026-01-01T00:00:00Z",
"notAfter": "2027-01-01T00:00:00Z",
"expectedReason": null
},
{
"name": "intermediate link one second beyond its maximum validity",
"role": "intermediate",
"notBefore": "2026-01-01T00:00:00Z",
"notAfter": "2027-01-01T00:00:01Z",
"expectedReason": "TRUST_CHAIN_INVALID"
},
{
"name": "signing link at its maximum validity",
"role": "signing",
"notBefore": "2026-08-01T00:00:00Z",
"notAfter": "2026-09-01T00:00:00Z",
"expectedReason": null
},
{
"name": "signing link one second beyond its maximum validity",
"role": "signing",
"notBefore": "2026-08-01T00:00:00Z",
"notAfter": "2026-09-01T00:00:01Z",
"expectedReason": "TRUST_CHAIN_INVALID"
}
],
"postSignaturePolicyVectors": [
{
"name": "overlong challenge expires at the frozen seven-day boundary",
"issuedAt": "2026-08-01T00:00:00Z",
"declaredExpiresAt": "2026-08-09T00:00:00Z",
"evaluationTime": "2026-08-08T00:05:01Z",
"expectedEffectiveExpiresAt": "2026-08-08T00:00:00Z",
"expectedReason": "CHALLENGE_EXPIRED"
},
{
"name": "overlong challenge remains valid at the frozen boundary plus tolerance",
"issuedAt": "2026-08-01T00:00:00Z",
"declaredExpiresAt": "2026-08-09T00:00:00Z",
"evaluationTime": "2026-08-08T00:05:00Z",
"expectedEffectiveExpiresAt": "2026-08-08T00:00:00Z",
"expectedReason": null
}
]
}
@@ -11,6 +11,12 @@
"A rejection never reports which of several failed checks failed first beyond the single frozen reason."
],
"reasons": [
{
"reason": "DOCUMENT_MALFORMED",
"httpStatus": 400,
"status": "INVALID_ARGUMENT",
"meaning": "An enrollment challenge envelope or decoded challenge document cannot provide the padded-base64 JSON structure and pre-verification fields required to locate its trust chain and signing key. No partial challenge is processed."
},
{
"reason": "UNSUPPORTED_PROTOCOL",
"httpStatus": 400,
@@ -27,7 +33,7 @@
"reason": "SIGNATURE_MALFORMED",
"httpStatus": 400,
"status": "INVALID_ARGUMENT",
"meaning": "The signature is not 64 octets of fixed-width r||s in unpadded base64url, or r or s is out of range."
"meaning": "A top-level challenge or response signature algorithm is not ES256, keyId is malformed, the value is not 64 octets of fixed-width r||s in unpadded base64url, or r or s is out of range. A trust-link signature failure is TRUST_CHAIN_INVALID instead."
},
{
"reason": "SIGNATURE_NOT_CANONICAL",
@@ -51,7 +57,7 @@
"reason": "TRUST_CHAIN_INVALID",
"httpStatus": 401,
"status": "UNAUTHENTICATED",
"meaning": "A trust link failed its own signature check, named the wrong issuer, carried an unknown role, or was outside its validity at the challenge issuedAt."
"meaning": "The pinned-root chain has the wrong length or role order, or a trust link is structurally invalid, exceeds its role validity limit, fails its signature check, names the wrong issuer, or is outside its validity at the challenge issuedAt."
},
{
"reason": "CONNECT_KEY_UNCHAINED",
@@ -75,7 +81,7 @@
"reason": "CHALLENGE_EXPIRED",
"httpStatus": 401,
"status": "UNAUTHENTICATED",
"meaning": "The evaluation time is more than the skew tolerance after expiresAt."
"meaning": "The evaluation time is more than the skew tolerance after effectiveExpiresAt, which never exceeds issuedAt plus the frozen maximum challenge lifetime."
},
{
"reason": "CHALLENGE_PROOF_INVALID",
@@ -22,7 +22,8 @@
"keyIdOver": "DER SubjectPublicKeyInfo",
"keyIdEncoding": "lowercase-hex",
"keyIdPattern": "^[0-9a-f]{64}$",
"documentTransferEncoding": "base64-padded"
"documentTransferEncoding": "base64-padded",
"documentTransferValidation": "strict standard base64 whose length is a multiple of four and whose decode then encode result is byte-identical to the received value"
},
"domainSeparation": {
"rule": "signatureInput = domainSeparationTag || 0x00 || the exact raw octets of the signed document as transmitted",
@@ -39,6 +40,7 @@
}
},
"verifierMustReject": [
"An envelope or decoded document that is not valid JSON, bytes that are not strict padded base64, or a field required before signature verification that is absent or malformed.",
"A signature that is not exactly 64 octets of fixed-width r||s.",
"A DER or any other ASN.1 encoded signature, even when it decodes to the same r and s.",
"A signature encoded with the standard base64 alphabet or with = padding.",
@@ -89,25 +91,61 @@
],
"verificationOrder": {
"principle": "Parse as late as the verification key allows, and treat anything read before the signature verified as untrusted routing information rather than as a fact.",
"enrollmentFailureReasons": {
"challengeDocumentMalformed": {
"appliesTo": "enrollmentChallenge",
"reason": "DOCUMENT_MALFORMED",
"covers": [
"an envelope that is not a JSON object or lacks document.bytes or document.signature",
"document.bytes that is not strict standard padded base64",
"decoded document bytes that are not a JSON object",
"a missing or ill-typed challenge field required before signature verification",
"issuedAt that is not a real RFC 3339 UTC instant at second precision"
],
"noPartialProcessing": true
},
"artifactSignatureAlgorithm": {
"appliesTo": [
"enrollmentChallenge",
"enrollmentResponse"
],
"supported": "ES256",
"reason": "SIGNATURE_MALFORMED"
},
"artifactSignatureKeyId": {
"malformedReason": "SIGNATURE_MALFORMED",
"challengeMismatchReason": "SIGNATURE_INVALID",
"responseMismatchReason": "DEVICE_PROOF_INVALID"
},
"trustLink": {
"invalidFormatVersionReason": "TRUST_CHAIN_INVALID",
"invalidProtocolVersionReason": "TRUST_CHAIN_INVALID",
"missingRequiredFieldReason": "TRUST_CHAIN_INVALID",
"invalidRoleReason": "TRUST_CHAIN_INVALID",
"invalidSignatureAlgorithmReason": "TRUST_CHAIN_INVALID",
"excessiveValidityReason": "TRUST_CHAIN_INVALID"
}
},
"enrollmentChallenge": {
"note": "A challenge carries its own chain, so the CLI must read structure before it can verify anything. The pre-parse yields only trustChain, connectKeyId, and issuedAt, and none of them is believed: the chain has to close on a pinned root, and the challenge signature has to verify, before any other field is used.",
"note": "A challenge carries its own chain, so the CLI must read structure before it can verify anything. The pre-parse yields only trustChain, connectKeyId, and issuedAt, and none of them is believed. When a first link is present and readable, its issuer is checked against the pinned roots before chain length or role checks. This precedence deliberately makes an unpinned-root artifact ENROLLMENT_ROOT_UNKNOWN even when the rest of its chain is malformed.",
"steps": [
"check the signature encoding",
"pre-parse the untrusted document for trustChain, connectKeyId, and issuedAt",
"decode the envelope and document bytes, then pre-parse the untrusted document for trustChain, connectKeyId, and issuedAt; reject an unreadable value as DOCUMENT_MALFORMED",
"check signature.algorithm and the signature encoding; reject a non-ES256 algorithm or malformed encoding as SIGNATURE_MALFORMED",
"pre-parse the first trust link for an issuerKeyId matching the frozen keyId pattern; reject an unreadable or malformed value as DOCUMENT_MALFORMED",
"reject unless trustChain[0].issuerKeyId is a pinned root",
"verify every trust link against its issuer and its validity at issuedAt",
"require exactly two links and the positional roles [intermediate, signing], then verify every required field, version, algorithm, signature, issuer binding, role validity limit, and validity at issuedAt; reject any failure as TRUST_CHAIN_INVALID",
"reject unless connectKeyId is the subject of the last link",
"verify the challenge signature over the received octets",
"only now read protocolVersion, then formatVersion",
"check the freshness window"
"only now read protocolVersion, then formatVersion, then validate the remaining required document fields",
"check the freshness window against effectiveExpiresAt = min(expiresAt, issuedAt + maxChallengeLifetimeSeconds)"
]
},
"enrollmentResponse": {
"note": "A response presents the device key it is enrolling, so Connect necessarily reads that key from the document. Proof of possession is what makes it safe: the presented key must be the key that signed the presenting document.",
"steps": [
"check the signature encoding",
"reject unless deviceKeyId is the fingerprint of devicePublicKey and the signature verifies under devicePublicKey",
"only now read protocolVersion, then formatVersion",
"check signature.algorithm and the signature encoding; reject a non-ES256 algorithm or malformed encoding as SIGNATURE_MALFORMED",
"pre-parse deviceKeyId and devicePublicKey, then reject an unreadable value or unless deviceKeyId is the fingerprint of devicePublicKey and the signature verifies under devicePublicKey as DEVICE_PROOF_INVALID",
"only now read protocolVersion, then formatVersion, then validate the remaining required document fields",
"compare organization, then cluster, against the stored challenge",
"compare challengeId, challengeNonce, and challengeProof against the stored challenge",
"check the freshness window against producedAt, then against the receive time",
@@ -146,6 +184,11 @@
"chainLinkCount": 2,
"maxChainLinkCount": 2,
"chainOrder": "index 0 is issued by a pinned root, index 1 is issued by the subject of index 0",
"chainRoles": [
"intermediate",
"signing"
],
"rootCheckPrecedesChainShape": true,
"note": "Because no root is ever learned at runtime, an operator cannot be socially engineered into accepting an attacker root, and a stolen intermediate cannot mint its own root. The cost is that a root rollover requires redistributing the RustFS build, which is stated in rollover.root."
},
"keyHierarchy": [
@@ -242,12 +285,14 @@
"clockSkew": {
"toleranceSeconds": 300,
"deviceClockAuthority": "advisory",
"challengeWindow": "accepted while verifierNow is within [issuedAt - 300, expiresAt + 300]",
"challengeWindow": "accepted while verifierNow is within [issuedAt - 300, effectiveExpiresAt + 300], where effectiveExpiresAt = min(expiresAt, issuedAt + maxChallengeLifetimeSeconds)",
"chainLinkWindow": "each link must satisfy notBefore <= challenge.issuedAt <= notAfter, evaluated with no tolerance because the issuer controls both values",
"maxChallengeLifetimeSeconds": 604800,
"excessiveChallengeLifetimePolicy": "clamp-effective-expiry",
"excessiveChallengeLifetimeReason": "CHALLENGE_EXPIRED",
"maxManifestAgeSeconds": 2592000,
"maxManifestFutureSkewSeconds": 300,
"responseWindow": "producedAt must fall within [challenge.issuedAt - 300, challenge.expiresAt + 300]",
"responseWindow": "producedAt must fall within [challenge.issuedAt - 300, effectiveExpiresAt + 300]",
"note": "ADR 0003 already treats client clocks as advisory for heartbeat freshness. An air-gapped device is worse: it may have no synchronised clock at all. Every window is therefore evaluated against the Connect clock for artifacts Connect receives, and against the issuer-supplied issuedAt for the chain a device validates locally."
},
"replay": {
@@ -1,4 +1,4 @@
812b0ba479a4c8d8eb9776e7bcb8d4c4d929bb83f372c03bec064472bca6155a accept-vectors.json
eb197077a2db61ae3114fa52cdeb32715f8060f6ce5f2b9bae7fe7e7f78b4981 error-codes.json
3940cc260b21a8655e5ebbdbeccd06a273d2299ce783eef92b22cacdaebc80e1 reject-vectors.json
58a7126cef796dd0631b2de8d31528267e6281566646a5662dd3ad555a530008 transcript.json
1c8cf2e5c7428dc1d41104aaf05855efc157d0a739e904ed8ba02146c711ee91 error-codes.json
09b849e3a7e4a9ee0829f44ae10318d5ea9c7f0196a60e8b122566e4201895b8 reject-vectors.json
1dea462159b23ce2640e8b0a68fb014d48dd3344ac24db55b35c1a1d82055ddc transcript.json
@@ -60,6 +60,14 @@
"definedBy": "protocol/agent/v1/registration-proof.md",
"note": "Separate from CERTIFICATE_REQUEST_MALFORMED because the request is structurally fine and the refusal is a policy one: ADR 0008 fixes the device key and this surface may not widen it."
},
{
"reason": "CERTIFICATE_REQUEST_PROFILE_UNSUPPORTED",
"httpStatus": 400,
"status": "INVALID_ARGUMENT",
"meaning": "The CSR subject or a typed subject alternative name cannot round-trip through the pinned stock step-ca JWK authorization strings without changing its ASN.1 type.",
"definedBy": "protocol/agent/v1/registration-proof.md",
"note": "Stable and non-retryable. No SAN is valid and is the RustFS-generated profile. IP, absolute URI, email containing @, and remaining DNS strings are valid only in their matching GeneralName choice. These names authorize the CSR key only; the issued CN and sole URI SAN still come only from Connect's assigned device uid."
},
{
"reason": "REGISTRATION_TOKEN_UNUSABLE",
"httpStatus": 401,
@@ -809,6 +809,37 @@
"reason": "REGISTRATION_PROOF_INVALID",
"verifiesMathematically": false
}
},
{
"name": "DNS subject alternative name whose value is classified as an IP address",
"stage": "certificateRequest",
"evaluatedAt": "2026-08-28T12:00:00Z",
"note": "The ASN.1 GeneralName is dNSName, but the pinned stock step-ca JWK path classifies the untyped authorization string 10.0.0.1 as an IP address before comparing it with the CSR. Connect refuses the mismatch as a stable protocol decision before asking the authority to guess.",
"tokenRecord": {
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
"expiresUnix": 1787228100,
"state": "ACTIVE"
},
"request": {
"protocolVersion": "v1",
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
"certificateRequest": "MIIBETCBtwIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR+TUddmyykdETq9JrvaQiHpzKCOGetvSPiPNKhP9ydmEYPt2k98OIkpzcIDHLbCE+HixdoZ1WWSqzOF9L3dRKboCYwJAYJKoZIhvcNAQkOMRcwFTATBgNVHREEDDAKgggxMC4wLjAuMTAKBggqhkjOPQQDAgNJADBGAiEA6q1VFU3kftE89kFMG0uEnPRSAs+GdTk+GB6q2xuR8Q8CIQCHvwA+1cR9xyJgtX+XUFJsNtMWsdjxajAxeHyTeFtOsA==",
"proof": {
"algorithm": "ES256",
"value": "iULWfq3BzJQ2mIqFkZomPHAXahWjbUP1ETO8KBIRr-s9RnUdst7MP_kuaizIZozfAmhaKIOImCejwpptE_9atQ"
}
},
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:l86VX044RFdnKAS-VCo2bfNDjJFiNsxkZWXQfsydc5c\n",
"serverTranscriptSha256": "6f944e6044aa001e68b56c2409da76c179f52dc4e6d171de45bee52e617c12d6",
"expected": {
"accepted": false,
"reason": "CERTIFICATE_REQUEST_PROFILE_UNSUPPORTED",
"verifiesMathematically": false
}
}
]
}
@@ -228,10 +228,25 @@
"sanUsed": false,
"extensionsUsed": false,
"attributesUsed": false,
"authorizationCompatibility": {
"decision": "constrained CSR profile for the pinned stock step-ca JWK authorization path",
"reason": "The JWK token carries SANs as strings and step-ca classifies each string by content before comparing it with the typed PKCS#10 GeneralName.",
"noSubjectAlternativeNameAccepted": true,
"matchingTypes": {
"iPAddress": "a parsed IPv4 or IPv6 address",
"uniformResourceIdentifier": "an absolute URI with a conventional scheme, valid percent escapes, and no control octets",
"rfc822Name": "a non-IP, non-URI value containing @",
"dNSName": "every remaining non-empty string"
},
"unsupportedGeneralNamesRejected": true,
"mismatchedTypeReason": "CERTIFICATE_REQUEST_PROFILE_UNSUPPORTED",
"retryable": false,
"identityBoundary": "The names are mirrored only so the authority can validate proof of possession. The issued CN and sole URI SAN are rendered from Connect's signed deviceUid claim and checked after issuance."
},
"claimedDeviceUidInFixtures": "0198f4b0-8b00-7d80-9491-9fa0b1c2d3e7",
"claimedSubjectAlternativeNameInFixtures": "urn:rustfs:connect:device:0198f4b0-8b00-7d80-9491-9fa0b1c2d3e7",
"claimedIdentityNote": "Every certificate request in this set carries the subject CN=0198f4b0-8b00-7d80-9491-9fa0b1c2d3e7 and the matching device URN as its only subject alternative name. Connect assigned no such device, and no vector references that uid anywhere else. A verifier that reads an identity out of a certificate request will visibly agree with a value nothing else in the exchange corroborates, which is easier to notice than an omission.",
"ignoredFieldsNote": "Connect consumes a certificate request for its SubjectPublicKeyInfo and its self-signature and for nothing else. The subject, the subject alternative names, any requested extensions, and any attributes are ignored and are never copied into the issued certificate. A device cannot name itself: ADR 0008 fixes the issued subject as CN=<clusterDeviceUid> and the SAN as urn:rustfs:connect:device:<clusterDeviceUid>, and Connect assigns that uid during this exchange. A device has no uid to put in a certificate request, which is the structural reason the request cannot be the source of its own identity.",
"ignoredFieldsNote": "subjectUsed and sanUsed mean used as identity or copied into the certificate; both are false. Connect mirrors compatible names into a minute-scale CA authorization token only so stock step-ca can compare them with this CSR. Requested extensions and attributes remain unused. A device cannot name itself: ADR 0008 fixes the issued subject as CN=<clusterDeviceUid> and the SAN as urn:rustfs:connect:device:<clusterDeviceUid>, and Connect assigns that uid during this exchange. A device has no uid to put in a certificate request, which is the structural reason the request cannot be the source of its own identity.",
"selfSignatureAloneIsInsufficient": "A valid self-signature proves only that somebody holds the key in the request. It binds no token, no tenant, no cluster, and no attempt, so a verifier that stopped there would issue a device certificate to any key presented with any stolen token. reject-vectors.json publishes exactly that vector under \"accepted proof presented with a substituted certificate request\"."
}
},
@@ -245,6 +260,7 @@
"decode the certificate request, refuse anything that is not one well-formed PKCS#10 DER with no trailing octets with CERTIFICATE_REQUEST_MALFORMED",
"refuse a SubjectPublicKeyInfo that is not an ECDSA key on P-256 with DEVICE_KEY_UNSUPPORTED",
"refuse a certificate request whose ES256 self-signature does not verify under its own key with CERTIFICATE_REQUEST_MALFORMED",
"refuse a certificate request whose subject or typed SANs cannot round-trip through the pinned stock step-ca authorization strings with CERTIFICATE_REQUEST_PROFILE_UNSUPPORTED",
"resolve the registration token by uid and secret digest and refuse anything not usable now with REGISTRATION_TOKEN_UNUSABLE",
"rebuild the transcript from the resolved row plus requestId and the recomputed certificate request digest",
"verify the proof over those octets under the certificate request key and refuse with REGISTRATION_PROOF_INVALID"
@@ -255,6 +271,7 @@
"SIGNATURE_NOT_CANONICAL",
"CERTIFICATE_REQUEST_MALFORMED",
"DEVICE_KEY_UNSUPPORTED",
"CERTIFICATE_REQUEST_PROFILE_UNSUPPORTED",
"REGISTRATION_PROOF_INVALID"
],
"ownedElsewhere": [