mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-05 19:55:37 +00:00
fix(connect): sync protocol fixture consumers
This commit is contained in:
@@ -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": [
|
||||
|
||||
@@ -220,8 +220,10 @@ impl DeviceIdentity {
|
||||
/// Build the PKCS#10 certificate request Connect consumes.
|
||||
///
|
||||
/// Connect reads the request for its SubjectPublicKeyInfo and its
|
||||
/// self-signature and for nothing else: it assigns the device uid itself,
|
||||
/// so the subject and SAN carried here name nothing Connect will honour.
|
||||
/// self-signature. The generated profile deliberately has no subject
|
||||
/// alternative name, so the stock CA authorization path cannot
|
||||
/// reinterpret an untyped name as a different ASN.1 GeneralName. Connect
|
||||
/// assigns the issued subject and device URI itself.
|
||||
pub fn certificate_request_der(&self) -> Result<Vec<u8>, IdentityError> {
|
||||
let pkcs8 = self.to_pkcs8_der()?;
|
||||
let key_pair =
|
||||
|
||||
@@ -110,6 +110,9 @@ const PUBLIC_KEY_CHARS: usize = 87;
|
||||
/// SEC1 tag of an uncompressed point. Compressed and hybrid forms are refused.
|
||||
const UNCOMPRESSED_POINT: u8 = 0x04;
|
||||
|
||||
const KEY_ID_CHARS: usize = 64;
|
||||
const SERIAL_CHARS: usize = 32;
|
||||
|
||||
const TIMESTAMP_CHARS: usize = 20;
|
||||
|
||||
/// The chain is exactly two links: a pinned root issues the intermediate, and
|
||||
@@ -117,6 +120,7 @@ const TIMESTAMP_CHARS: usize = 20;
|
||||
/// enumeration is closed.
|
||||
const CHAIN_LINK_COUNT: usize = 2;
|
||||
const CHAIN_ROLES: [&str; CHAIN_LINK_COUNT] = ["intermediate", "signing"];
|
||||
const CHAIN_MAX_VALIDITY_SECONDS: [i64; CHAIN_LINK_COUNT] = [31_536_000, 2_678_400];
|
||||
|
||||
/// Skew allowed on the challenge window. A device may have no synchronised
|
||||
/// clock at all, so its own reading of "now" is advisory.
|
||||
@@ -212,9 +216,7 @@ pub enum EnrollmentError {
|
||||
|
||||
/// The artifact could not be read as a signed enrolment document at all: the
|
||||
/// envelope, the base64 of the signed octets, or a field the frozen order
|
||||
/// reads before the signature verifies did not parse. The frozen reason set
|
||||
/// has no code for a structurally unreadable document, so this variant maps
|
||||
/// to none of them.
|
||||
/// reads before the signature verifies did not parse.
|
||||
#[error("the offline enrollment document is not well formed")]
|
||||
MalformedDocument,
|
||||
|
||||
@@ -253,7 +255,7 @@ impl EnrollmentError {
|
||||
Self::EnrollmentReplayed => "ENROLLMENT_REPLAYED",
|
||||
Self::OrganizationMismatch => "ORGANIZATION_MISMATCH",
|
||||
Self::ClusterMismatch => "CLUSTER_MISMATCH",
|
||||
Self::MalformedDocument => "MALFORMED_DOCUMENT",
|
||||
Self::MalformedDocument => "DOCUMENT_MALFORMED",
|
||||
Self::ResponseNotProduced => "RESPONSE_NOT_PRODUCED",
|
||||
}
|
||||
}
|
||||
@@ -287,6 +289,12 @@ struct ChallengeRouting {
|
||||
trust_chain: Vec<SignedDocument>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TrustLinkRouting {
|
||||
issuer_key_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ChallengeDocument {
|
||||
@@ -306,6 +314,7 @@ struct ChallengeDocument {
|
||||
struct TrustLink {
|
||||
format_version: String,
|
||||
protocol_version: String,
|
||||
serial: String,
|
||||
role: String,
|
||||
issuer_key_id: String,
|
||||
subject_key_id: String,
|
||||
@@ -357,28 +366,45 @@ impl OfflineEnrollment {
|
||||
) -> Result<VerifiedChallenge, EnrollmentError> {
|
||||
let envelope: SignedDocument = serde_json::from_slice(document).map_err(|_| EnrollmentError::MalformedDocument)?;
|
||||
|
||||
// Step 1: the encoding is checked before anything is decoded from it, so
|
||||
// a DER, padded, truncated, out-of-range, or high-S signature is refused
|
||||
// on its spelling rather than handed to a library that would accept it.
|
||||
// Decode the exact transmitted octets before reading any routing field.
|
||||
// Standard padded base64 is canonical in this protocol: accepting an
|
||||
// alternate spelling would give one signed document multiple artifact
|
||||
// identities.
|
||||
let bytes = decode_document_bytes(&envelope.bytes)?;
|
||||
|
||||
// Routing only. These values are still untrusted, but they must be
|
||||
// structurally usable before the signature and trust decisions can be
|
||||
// made in their frozen order.
|
||||
let routing: ChallengeRouting = serde_json::from_slice(&bytes).map_err(|_| EnrollmentError::MalformedDocument)?;
|
||||
if !is_key_id(&routing.connect_key_id) {
|
||||
return Err(EnrollmentError::MalformedDocument);
|
||||
}
|
||||
let issued_at = parse_timestamp(&routing.issued_at)?;
|
||||
let first = routing.trust_chain.first().ok_or(EnrollmentError::MalformedDocument)?;
|
||||
let first_bytes = decode_document_bytes(&first.bytes)?;
|
||||
let first_routing: TrustLinkRouting =
|
||||
serde_json::from_slice(&first_bytes).map_err(|_| EnrollmentError::MalformedDocument)?;
|
||||
if !is_key_id(&first_routing.issuer_key_id) {
|
||||
return Err(EnrollmentError::MalformedDocument);
|
||||
}
|
||||
|
||||
// Only after the document can route verification do we classify the
|
||||
// top-level signature spelling and algorithm.
|
||||
let signature = decode_signature(&envelope.signature)?;
|
||||
|
||||
// The octets that were transmitted. They are never re-serialised: every
|
||||
// later step signs and parses this same buffer.
|
||||
let bytes = BASE64_STANDARD
|
||||
.decode_to_vec(envelope.bytes.as_bytes())
|
||||
.map_err(|_| EnrollmentError::MalformedDocument)?;
|
||||
|
||||
// Step 2: routing only.
|
||||
let routing: ChallengeRouting = serde_json::from_slice(&bytes).map_err(|_| EnrollmentError::MalformedDocument)?;
|
||||
let issued_at = parse_timestamp(&routing.issued_at)?;
|
||||
|
||||
// Steps 3 to 5.
|
||||
let connect_key = verify_trust_chain(&routing.trust_chain, &routing.connect_key_id, issued_at, root)?;
|
||||
let connect_key = verify_trust_chain(
|
||||
&routing.trust_chain,
|
||||
&routing.connect_key_id,
|
||||
&first_routing.issuer_key_id,
|
||||
issued_at,
|
||||
root,
|
||||
)?;
|
||||
|
||||
// Step 6. The verification key comes from the chain, so `signature.keyId`
|
||||
// is a label rather than an input: a value naming some other key simply
|
||||
// fails to verify here.
|
||||
if !verifies(&connect_key, TAG_CHALLENGE, &bytes, &signature) {
|
||||
// Step 6. The detached signature must name the same chained key whose
|
||||
// public key verifies it. A different well-formed key id is a signature
|
||||
// failure, not an opportunity to ignore the binding.
|
||||
if envelope.signature.key_id != routing.connect_key_id || !verifies(&connect_key, TAG_CHALLENGE, &bytes, &signature) {
|
||||
return Err(EnrollmentError::SignatureInvalid);
|
||||
}
|
||||
|
||||
@@ -478,6 +504,7 @@ fn e2e_root() -> Result<EnrollmentRoot, EnrollmentError> {
|
||||
fn verify_trust_chain(
|
||||
chain: &[SignedDocument],
|
||||
connect_key_id: &str,
|
||||
first_issuer_key_id: &str,
|
||||
challenge_issued_at: i64,
|
||||
root: EnrollmentRoot,
|
||||
) -> Result<VerifyingKey, EnrollmentError> {
|
||||
@@ -486,10 +513,10 @@ fn verify_trust_chain(
|
||||
// trust on first use would have accepted — is refused for its root rather
|
||||
// than for its length.
|
||||
let first = chain.first().ok_or(EnrollmentError::EnrollmentRootUnknown)?;
|
||||
let first_link = decode_trust_link(first)?;
|
||||
if first_link.0.issuer_key_id != root.key_id {
|
||||
if first_issuer_key_id != root.key_id {
|
||||
return Err(EnrollmentError::EnrollmentRootUnknown);
|
||||
}
|
||||
let first_link = decode_trust_link(first)?;
|
||||
|
||||
let [_, second] = chain else {
|
||||
return Err(EnrollmentError::TrustChainInvalid);
|
||||
@@ -502,8 +529,11 @@ fn verify_trust_chain(
|
||||
for (index, ((link, link_bytes), entry)) in links.iter().zip(chain).enumerate() {
|
||||
if link.format_version != FORMAT_TRUST_LINK
|
||||
|| link.protocol_version != PROTOCOL_VERSION
|
||||
|| !is_serial(&link.serial)
|
||||
|| link.role != CHAIN_ROLES[index]
|
||||
|| link.issuer_key_id != issuer_key_id
|
||||
|| !is_key_id(&link.issuer_key_id)
|
||||
|| !is_key_id(&link.subject_key_id)
|
||||
// A link that names itself as its own issuer would let a stolen
|
||||
// intermediate mint its own root.
|
||||
|| link.subject_key_id == link.issuer_key_id
|
||||
@@ -517,8 +547,8 @@ fn verify_trust_chain(
|
||||
return Err(EnrollmentError::TrustChainInvalid);
|
||||
}
|
||||
|
||||
let signature = decode_signature(&entry.signature)?;
|
||||
if !verifies(&issuer_key, TAG_TRUST_LINK, link_bytes, &signature) {
|
||||
let signature = decode_signature(&entry.signature).map_err(|_| EnrollmentError::TrustChainInvalid)?;
|
||||
if entry.signature.key_id != link.issuer_key_id || !verifies(&issuer_key, TAG_TRUST_LINK, link_bytes, &signature) {
|
||||
return Err(EnrollmentError::TrustChainInvalid);
|
||||
}
|
||||
|
||||
@@ -526,9 +556,12 @@ fn verify_trust_chain(
|
||||
// with no skew tolerance, and against the challenge's issuedAt rather
|
||||
// than against the device clock: a challenge carries the chain that was
|
||||
// valid when it was issued.
|
||||
let not_before = parse_timestamp(&link.not_before)?;
|
||||
let not_after = parse_timestamp(&link.not_after)?;
|
||||
if challenge_issued_at < not_before || challenge_issued_at > not_after {
|
||||
let not_before = parse_timestamp(&link.not_before).map_err(|_| EnrollmentError::TrustChainInvalid)?;
|
||||
let not_after = parse_timestamp(&link.not_after).map_err(|_| EnrollmentError::TrustChainInvalid)?;
|
||||
if !link_validity_allowed(not_before, not_after, CHAIN_MAX_VALIDITY_SECONDS[index])
|
||||
|| challenge_issued_at < not_before
|
||||
|| challenge_issued_at > not_after
|
||||
{
|
||||
return Err(EnrollmentError::TrustChainInvalid);
|
||||
}
|
||||
|
||||
@@ -546,20 +579,33 @@ fn verify_trust_chain(
|
||||
/// Decode a link and keep the octets it was signed over: the signature is
|
||||
/// checked against these, never against a re-encoding of the parsed link.
|
||||
fn decode_trust_link(entry: &SignedDocument) -> Result<(TrustLink, Vec<u8>), EnrollmentError> {
|
||||
let bytes = BASE64_STANDARD
|
||||
.decode_to_vec(entry.bytes.as_bytes())
|
||||
.map_err(|_| EnrollmentError::MalformedDocument)?;
|
||||
let bytes = decode_document_bytes(&entry.bytes).map_err(|_| EnrollmentError::TrustChainInvalid)?;
|
||||
let link = serde_json::from_slice(&bytes).map_err(|_| EnrollmentError::TrustChainInvalid)?;
|
||||
Ok((link, bytes))
|
||||
}
|
||||
|
||||
fn decode_document_bytes(value: &str) -> Result<Vec<u8>, EnrollmentError> {
|
||||
if value.len() % 4 != 0 {
|
||||
return Err(EnrollmentError::MalformedDocument);
|
||||
}
|
||||
|
||||
let decoded = BASE64_STANDARD
|
||||
.decode_to_vec(value.as_bytes())
|
||||
.map_err(|_| EnrollmentError::MalformedDocument)?;
|
||||
if BASE64_STANDARD.encode_to_string(&decoded) != value {
|
||||
return Err(EnrollmentError::MalformedDocument);
|
||||
}
|
||||
|
||||
Ok(decoded)
|
||||
}
|
||||
|
||||
/// Check a signature's spelling and range, then admit it.
|
||||
///
|
||||
/// `r` and `s` are compared against the group order here rather than left to
|
||||
/// the ECDSA library, because a library that accepts high-S — every library
|
||||
/// does — would let a malleated copy of an artifact pass as a second artifact.
|
||||
fn decode_signature(signature: &DocumentSignature) -> Result<Signature, EnrollmentError> {
|
||||
if signature.algorithm != SIGNATURE_ALGORITHM {
|
||||
if signature.algorithm != SIGNATURE_ALGORITHM || !is_key_id(&signature.key_id) {
|
||||
return Err(EnrollmentError::SignatureMalformed);
|
||||
}
|
||||
|
||||
@@ -590,6 +636,24 @@ fn decode_signature(signature: &DocumentSignature) -> Result<Signature, Enrollme
|
||||
Signature::from_slice(&octets).map_err(|_| EnrollmentError::SignatureMalformed)
|
||||
}
|
||||
|
||||
fn is_key_id(value: &str) -> bool {
|
||||
value.len() == KEY_ID_CHARS
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
}
|
||||
|
||||
fn is_serial(value: &str) -> bool {
|
||||
value.len() == SERIAL_CHARS
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
}
|
||||
|
||||
fn link_validity_allowed(not_before: i64, not_after: i64, maximum: i64) -> bool {
|
||||
not_after > not_before && not_after - not_before <= maximum
|
||||
}
|
||||
|
||||
fn verifies(key: &VerifyingKey, tag: &[u8], bytes: &[u8], signature: &Signature) -> bool {
|
||||
key.verify(&signature_input(tag, bytes), signature).is_ok()
|
||||
}
|
||||
@@ -723,3 +787,41 @@ fn check_challenge_window(issued_at: i64, expires_at: i64, at: i64) -> Result<()
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn trust_link_validity_limits_match_the_frozen_boundary_vectors() {
|
||||
let fixture: serde_json::Value = serde_json::from_str(include_str!(
|
||||
"../../../../protocol/agent/v1/fixtures/offline-enrollment/boundary-vectors.json"
|
||||
))
|
||||
.expect("boundary vectors parse");
|
||||
let vectors = fixture["linkValidityPolicyVectors"]
|
||||
.as_array()
|
||||
.expect("link validity policy vectors are a list");
|
||||
|
||||
for vector in vectors {
|
||||
let name = vector["name"].as_str().expect("vector has a name");
|
||||
let role = vector["role"].as_str().expect("vector has a role");
|
||||
let index = CHAIN_ROLES
|
||||
.iter()
|
||||
.position(|candidate| *candidate == role)
|
||||
.expect("known chain role");
|
||||
let not_before =
|
||||
parse_timestamp(vector["notBefore"].as_str().expect("notBefore is a string")).expect("notBefore is an instant");
|
||||
let not_after =
|
||||
parse_timestamp(vector["notAfter"].as_str().expect("notAfter is a string")).expect("notAfter is an instant");
|
||||
let expected = vector["expectedReason"].is_null();
|
||||
|
||||
assert_eq!(
|
||||
link_validity_allowed(not_before, not_after, CHAIN_MAX_VALIDITY_SECONDS[index]),
|
||||
expected,
|
||||
"link validity policy vector '{name}'"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(vectors.len(), 4, "boundary-vectors.json publishes four link-validity vectors");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use base64_simd::URL_SAFE_NO_PAD as BASE64_URL_NO_PAD;
|
||||
use rustfs::connect::identity::{DeviceIdentity, IdentityError, RegistrationTranscript};
|
||||
use rustfs::connect::identity_store::{IdentityStore, StoreError};
|
||||
use x509_parser::prelude::{FromDer as _, X509CertificationRequest};
|
||||
|
||||
fn transcript_fixture() -> serde_json::Value {
|
||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../protocol/agent/v1/fixtures/registration/transcript.json");
|
||||
@@ -34,6 +35,11 @@ fn accept_vectors() -> serde_json::Value {
|
||||
serde_json::from_slice(&fs::read(path).expect("read accept-vectors.json")).expect("accept-vectors.json parses")
|
||||
}
|
||||
|
||||
fn reject_vectors() -> serde_json::Value {
|
||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../protocol/agent/v1/fixtures/registration/reject-vectors.json");
|
||||
serde_json::from_slice(&fs::read(path).expect("read reject-vectors.json")).expect("reject-vectors.json parses")
|
||||
}
|
||||
|
||||
/// Extract the SubjectPublicKeyInfo from a PKCS#10 request.
|
||||
///
|
||||
/// The protocol freezes the DER prefix of a P-256 SubjectPublicKeyInfo, and the
|
||||
@@ -359,6 +365,33 @@ fn certificate_request_presents_a_p256_key() {
|
||||
assert_eq!(der[0], 0x30, "a PKCS#10 request is a DER SEQUENCE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certificate_request_uses_the_frozen_no_san_authorization_profile() {
|
||||
let profile = &transcript_fixture()["request"]["certificateRequestProfile"]["authorizationCompatibility"];
|
||||
assert_eq!(profile["noSubjectAlternativeNameAccepted"].as_bool(), Some(true));
|
||||
assert_eq!(profile["mismatchedTypeReason"].as_str(), Some("CERTIFICATE_REQUEST_PROFILE_UNSUPPORTED"));
|
||||
|
||||
let identity = DeviceIdentity::generate();
|
||||
let der = identity.certificate_request_der().expect("certificate request builds");
|
||||
let (remaining, request) = X509CertificationRequest::from_der(&der).expect("certificate request parses");
|
||||
assert!(remaining.is_empty(), "the certificate request has no trailing octets");
|
||||
assert!(
|
||||
request
|
||||
.requested_extensions()
|
||||
.is_none_or(|mut extensions| extensions.next().is_none()),
|
||||
"RustFS must not send a subject alternative name in its registration request"
|
||||
);
|
||||
|
||||
let profile_vector = reject_vectors()["vectors"]
|
||||
.as_array()
|
||||
.expect("reject vectors are a list")
|
||||
.iter()
|
||||
.find(|vector| vector["expected"]["reason"] == "CERTIFICATE_REQUEST_PROFILE_UNSUPPORTED")
|
||||
.expect("the frozen profile rejection vector exists")
|
||||
.clone();
|
||||
assert_eq!(profile_vector["stage"].as_str(), Some("certificateRequest"));
|
||||
}
|
||||
|
||||
fn hex_to_bytes(hex: &str) -> Vec<u8> {
|
||||
(0..hex.len())
|
||||
.step_by(2)
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use base64_simd::STANDARD as BASE64_STANDARD;
|
||||
use base64_simd::URL_SAFE_NO_PAD as BASE64_URL_NO_PAD;
|
||||
@@ -45,6 +46,11 @@ const SPKI_PREFIX_HEX: &str = "3059301306072a8648ce3d020106082a8648ce3d030107034
|
||||
/// `clockSkew.toleranceSeconds` in `trust-model.json`.
|
||||
const SKEW_TOLERANCE_SECONDS: i64 = 300;
|
||||
|
||||
/// Fixture reads use real descriptors, while the offline invariant below
|
||||
/// snapshots the process descriptor table. A write guard around that snapshot
|
||||
/// keeps parallel test fixture I/O from masquerading as network activity.
|
||||
static FIXTURE_ACCESS: RwLock<()> = RwLock::new(());
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture access
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -73,6 +79,7 @@ fn sha256_hex(bytes: &[u8]) -> String {
|
||||
/// redefining what conformance means, which is the failure mode a
|
||||
/// fixture-driven suite is otherwise blind to.
|
||||
fn read_fixture(name: &str) -> Vec<u8> {
|
||||
let _fixture_guard = FIXTURE_ACCESS.read().expect("fixture access lock");
|
||||
let dir = fixture_dir();
|
||||
let manifest = fs::read_to_string(dir.join("MANIFEST.sha256")).expect("read MANIFEST.sha256");
|
||||
|
||||
@@ -108,6 +115,10 @@ fn trust_model() -> Value {
|
||||
fixture_json("trust-model.json")
|
||||
}
|
||||
|
||||
fn boundary_vectors() -> Value {
|
||||
fixture_json("boundary-vectors.json")
|
||||
}
|
||||
|
||||
fn vector_list(fixture: &Value) -> Vec<Value> {
|
||||
fixture["vectors"].as_array().expect("fixture carries a vector list").clone()
|
||||
}
|
||||
@@ -142,6 +153,75 @@ fn signed_document(document: &Value) -> Value {
|
||||
serde_json::from_slice(&signed_octets(document)).expect("signed document parses")
|
||||
}
|
||||
|
||||
fn encoded_document(document: &Value) -> String {
|
||||
BASE64_STANDARD.encode_to_string(serde_json::to_vec(document).expect("document serialises"))
|
||||
}
|
||||
|
||||
fn apply_object_mutation(target: &mut Value, mutation: &Value) {
|
||||
let object = target.as_object_mut().expect("mutation target is an object");
|
||||
let name = field(mutation, "field");
|
||||
|
||||
match mutation["operation"].as_str().expect("mutation carries an operation") {
|
||||
"remove" => {
|
||||
object.remove(name);
|
||||
}
|
||||
"replace" => {
|
||||
object.insert(name.to_string(), mutation["value"].clone());
|
||||
}
|
||||
operation => panic!("unsupported object mutation {operation}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn boundary_artifact(source: &Value, mutation: &Value) -> Vec<u8> {
|
||||
if field(mutation, "scope") == "serializedEnvelope" {
|
||||
return field(mutation, "value").as_bytes().to_vec();
|
||||
}
|
||||
|
||||
let mut result = source["document"].clone();
|
||||
let scope = field(mutation, "scope");
|
||||
if scope == "envelopeBytes" {
|
||||
result["bytes"] = mutation["value"].clone();
|
||||
return envelope(&result);
|
||||
}
|
||||
if scope == "envelopeSignature" {
|
||||
apply_object_mutation(&mut result["signature"], mutation);
|
||||
return envelope(&result);
|
||||
}
|
||||
|
||||
let mut challenge = signed_document(&result);
|
||||
match scope {
|
||||
"challenge" => apply_object_mutation(&mut challenge, mutation),
|
||||
"challengeChain" => {
|
||||
let chain = challenge["trustChain"].as_array().expect("challenge carries a chain");
|
||||
challenge["trustChain"] = match mutation["operation"].as_str().expect("chain mutation carries an operation") {
|
||||
"keepFirst" => Value::Array(vec![chain[0].clone()]),
|
||||
"objectWithFirst" => {
|
||||
let mut object = serde_json::Map::new();
|
||||
object.insert("first".to_string(), chain[0].clone());
|
||||
Value::Object(object)
|
||||
}
|
||||
operation => panic!("unsupported chain mutation {operation}"),
|
||||
};
|
||||
}
|
||||
"trustLink" | "trustLinkSignature" => {
|
||||
let index = mutation["index"].as_u64().expect("trust-link mutation carries an index") as usize;
|
||||
let chain = challenge["trustChain"].as_array_mut().expect("challenge carries a chain");
|
||||
let link_envelope = &mut chain[index];
|
||||
if scope == "trustLinkSignature" {
|
||||
apply_object_mutation(&mut link_envelope["signature"], mutation);
|
||||
} else {
|
||||
let mut link = signed_document(link_envelope);
|
||||
apply_object_mutation(&mut link, mutation);
|
||||
link_envelope["bytes"] = Value::String(encoded_document(&link));
|
||||
}
|
||||
}
|
||||
other => panic!("unsupported boundary scope {other}"),
|
||||
}
|
||||
|
||||
result["bytes"] = Value::String(encoded_document(&challenge));
|
||||
envelope(&result)
|
||||
}
|
||||
|
||||
fn unix(rfc3339: &str) -> i64 {
|
||||
chrono::DateTime::parse_from_rfc3339(rfc3339)
|
||||
.unwrap_or_else(|error| panic!("'{rfc3339}' is not RFC 3339: {error}"))
|
||||
@@ -472,6 +552,86 @@ fn every_challenge_reject_vector_fails_with_its_frozen_reason() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_challenge_boundary_mutation_fails_with_its_frozen_reason() {
|
||||
let boundaries = boundary_vectors();
|
||||
let source = accept_vector_named(field(&boundaries, "sourceVector"));
|
||||
let now = unix(field(&source, "evaluationTime"));
|
||||
let mut covered = 0usize;
|
||||
|
||||
for group in ["preparseMutations", "verificationMutations"] {
|
||||
for mutation in boundaries[group].as_array().expect("boundary group is a list") {
|
||||
let name = field(mutation, "name");
|
||||
let expected = field(mutation, "expectedReason");
|
||||
let error = match OfflineEnrollment::verify_challenge(&boundary_artifact(&source, mutation), now) {
|
||||
Err(error) => error,
|
||||
Ok(_) => panic!("boundary mutation '{name}' must fail"),
|
||||
};
|
||||
assert_eq!(error.reason(), expected, "boundary mutation '{name}'");
|
||||
covered += 1;
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(covered, 16, "boundary-vectors.json publishes sixteen executable challenge mutations");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpinned_root_precedes_a_malformed_chain_shape() {
|
||||
let source = accept_vector_named("challenge signed by a chained signing key under the pinned root");
|
||||
let now = unix(field(&source, "evaluationTime"));
|
||||
let mut challenge_envelope = source["document"].clone();
|
||||
let mut challenge = signed_document(&challenge_envelope);
|
||||
let first_envelope = &mut challenge["trustChain"].as_array_mut().expect("challenge carries a chain")[0];
|
||||
let mut first_link = signed_document(first_envelope);
|
||||
first_link.as_object_mut().expect("trust link is an object").remove("serial");
|
||||
first_link["issuerKeyId"] = Value::String("5ff37910aa4d69949e2c488f98d6072f10a3c3e73d776698963872582644f731".to_string());
|
||||
first_envelope["bytes"] = Value::String(encoded_document(&first_link));
|
||||
challenge_envelope["bytes"] = Value::String(encoded_document(&challenge));
|
||||
|
||||
let error =
|
||||
OfflineEnrollment::verify_challenge(&envelope(&challenge_envelope), now).expect_err("an unpinned root must never verify");
|
||||
assert_eq!(
|
||||
error.reason(),
|
||||
"ENROLLMENT_ROOT_UNKNOWN",
|
||||
"the pinned-root decision must precede the rest of the attacker-controlled chain shape"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_production_honours_the_frozen_effective_challenge_expiry() {
|
||||
let key = DeviceIdentity::generate();
|
||||
let boundaries = boundary_vectors();
|
||||
let mut covered = 0usize;
|
||||
|
||||
for vector in boundaries["postSignaturePolicyVectors"]
|
||||
.as_array()
|
||||
.expect("post-signature policy vectors are a list")
|
||||
{
|
||||
let name = field(vector, "name");
|
||||
let challenge = VerifiedChallenge {
|
||||
challenge_id: "018f7e6d-9d6a-7d93-8f64-8b20b3384712".to_string(),
|
||||
organization_name: "organizations/01HZXQ9J2XW6R7V8T9Y0Z1A2B3".to_string(),
|
||||
cluster_name: "organizations/01HZXQ9J2XW6R7V8T9Y0Z1A2B3/clusters/01HZXQ9J2XW6R7V8T9Y0Z1A2B4".to_string(),
|
||||
nonce: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(),
|
||||
issued_at: field(vector, "issuedAt").to_string(),
|
||||
expires_at: field(vector, "declaredExpiresAt").to_string(),
|
||||
connect_key_id: "08e7295c8f9d043e22b2b80fdb1480b0bec060dacbce7de9dd2e3d583f93d7e8".to_string(),
|
||||
challenge_proof: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(),
|
||||
};
|
||||
let outcome = OfflineEnrollment::build_response(&challenge, &key, &[0x5a; 32], unix(field(vector, "evaluationTime")));
|
||||
|
||||
match vector["expectedReason"].as_str() {
|
||||
Some(expected) => assert_eq!(outcome.unwrap_err().reason(), expected, "post-signature policy vector '{name}'"),
|
||||
None => {
|
||||
outcome.unwrap_or_else(|error| panic!("post-signature policy vector '{name}' must pass: {}", error.reason()));
|
||||
}
|
||||
}
|
||||
covered += 1;
|
||||
}
|
||||
|
||||
assert_eq!(covered, 2, "boundary-vectors.json publishes two effective-expiry vectors");
|
||||
}
|
||||
|
||||
/// The response reject vectors are artifacts Connect refuses. This side never
|
||||
/// verifies a response, so the device-side statement is the stronger one: given
|
||||
/// the challenge each vector answers, `build_response` must not be capable of
|
||||
@@ -949,6 +1109,7 @@ fn enrollment_opens_no_descriptor_and_is_a_pure_byte_transform() {
|
||||
let document = envelope(&vector["document"]);
|
||||
let now = unix(field(&vector, "evaluationTime"));
|
||||
let key = DeviceIdentity::generate();
|
||||
let _fixture_guard = FIXTURE_ACCESS.write().expect("fixture access lock");
|
||||
|
||||
// Warm anything the test harness itself lazily opens before the baseline.
|
||||
let _ = open_descriptors();
|
||||
|
||||
Reference in New Issue
Block a user