Public: anyone can read this; the crew can change it.
A test pins SSH signature checks: another key, a changed message, an algorithm downgrade and a malformed blob are each refused #6924
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6902-a-test-pins-ssh-signature-checks-another → main 1 commit fork 1 thread · 1 unresolved
1 file
changed +189
−0
test/ssh-keys.test.ts added +189
| @@ -0,0 +1,189 @@ | ||
| 1 | /** | |
| 2 | * SSH signature checks (#6902): a good signature verifies, and another key, a | |
| 3 | * changed message, an algorithm downgrade and a malformed blob are each | |
| 4 | * refused without throwing. | |
| 5 | */ | |
| 6 | import { describe, expect, test } from "bun:test"; | |
| 7 | import { | |
| 8 | createPublicKey, | |
| 9 | generateKeyPairSync, | |
| 10 | sign, | |
| 11 | type KeyObject, | |
| 12 | } from "node:crypto"; | |
| 13 | import { | |
| 14 | fingerprintOf, | |
| 15 | keyAlgorithmMatches, | |
| 16 | verifySshSignature, | |
| 17 | } from "../src/ssh/keys.ts"; | |
| 18 | import { Writer } from "../src/ssh/wire.ts"; | |
| 19 | ||
| 20 | const message = new TextEncoder().encode("session-id and userauth payload"); | |
| 21 | ||
| 22 | function jwkBytes(key: KeyObject, field: string): Uint8Array { | |
| 23 | const jwk = createPublicKey(key).export({ format: "jwk" }) as Record< | |
| 24 | string, | |
| 25 | string | |
| 26 | >; | |
| 27 | return Buffer.from(jwk[field]!, "base64url"); | |
| 28 | } | |
| 29 | ||
| 30 | interface Pair { | |
| 31 | blob: Uint8Array; | |
| 32 | signWith(algo: string, msg: Uint8Array): Uint8Array; | |
| 33 | } | |
| 34 | ||
| 35 | function ed25519Pair(): Pair { | |
| 36 | const { privateKey } = generateKeyPairSync("ed25519"); | |
| 37 | return { | |
| 38 | blob: new Writer() | |
| 39 | .string("ssh-ed25519") | |
| 40 | .string(jwkBytes(privateKey, "x")) | |
| 41 | .build(), | |
| 42 | signWith: (algo, msg) => | |
| 43 | new Writer() | |
| 44 | .string(algo) | |
| 45 | .string(sign(null, msg, privateKey)) | |
| 46 | .build(), | |
| 47 | }; | |
| 48 | } | |
| 49 | ||
| 50 | function rsaPair(): Pair { | |
| 51 | const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); | |
| 52 | return { | |
| 53 | blob: new Writer() | |
| 54 | .string("ssh-rsa") | |
| 55 | .mpint(jwkBytes(privateKey, "e")) | |
| 56 | .mpint(jwkBytes(privateKey, "n")) | |
| 57 | .build(), | |
| 58 | signWith: (algo, msg) => { | |
| 59 | const hash = | |
| 60 | algo === "rsa-sha2-512" | |
| 61 | ? "sha512" | |
| 62 | : algo === "ssh-rsa" | |
| 63 | ? "sha1" | |
| 64 | : "sha256"; | |
| 65 | return new Writer() | |
| 66 | .string(algo) | |
| 67 | .string(sign(hash, msg, privateKey)) | |
| 68 | .build(); | |
| 69 | }, | |
| 70 | }; | |
| 71 | } | |
| 72 | ||
| 73 | const flip = (bytes: Uint8Array): Uint8Array => { | |
| 74 | const copy = bytes.slice(); | |
| 75 | copy[0] = copy[0]! ^ 1; | |
| 76 | return copy; | |
| 77 | }; | |
| 78 | ||
| 79 | describe("verifySshSignature", () => { | |
| 80 | const cases: [string, string, () => Pair][] = [ | |
| 81 | ["ssh-ed25519", "ssh-ed25519", ed25519Pair], | |
| 82 | ["rsa-sha2-256", "rsa-sha2-256", rsaPair], | |
| 83 | ["rsa-sha2-512", "rsa-sha2-512", rsaPair], | |
| 84 | ]; | |
| 85 | ||
| 86 | for (const [name, algo, make] of cases) { | |
| 87 | describe(name, () => { | |
| 88 | const pair = make(); | |
| 89 | const good = pair.signWith(algo, message); | |
| 90 | ||
| 91 | test("a good signature verifies", () => { | |
| 92 | expect(verifySshSignature(algo, pair.blob, good, message).ok).toBe( | |
| 93 | true, | |
| 94 | ); | |
| 95 | }); | |
| 96 | ||
| 97 | test("a signature by another key is refused", () => { | |
| 98 | const other = make(); | |
| 99 | const sig = other.signWith(algo, message); | |
| 100 | expect(verifySshSignature(algo, pair.blob, sig, message).ok).toBe( | |
| 101 | false, | |
| 102 | ); | |
| 103 | }); | |
| 104 | ||
| 105 | test("one changed byte of the message is refused", () => { | |
| 106 | expect( | |
| 107 | verifySshSignature(algo, pair.blob, good, flip(message)).ok, | |
| 108 | ).toBe(false); | |
| 109 | }); | |
| 110 | ||
| 111 | test("a truncated or garbage blob is refused without throwing", () => { | |
| 112 | const junk = [ | |
| 113 | new Uint8Array(0), | |
| 114 | good.subarray(0, 6), | |
| 115 | good.subarray(0, good.length - 5), | |
| 116 | new Uint8Array(40).fill(0xff), | |
| 117 | ]; | |
| 118 | for (const sig of junk) { | |
| 119 | expect(verifySshSignature(algo, pair.blob, sig, message).ok).toBe( | |
| 120 | false, | |
| 121 | ); | |
| 122 | } | |
| 123 | expect( | |
| 124 | verifySshSignature(algo, pair.blob.subarray(0, 10), good, message).ok, | |
| 125 | ).toBe(false); | |
| 126 | expect( | |
| 127 | verifySshSignature(algo, new Uint8Array(0), good, message).ok, | |
| 128 | ).toBe(false); | |
| 129 | }); | |
| 130 | }); | |
| 131 | } | |
| 132 | ||
| 133 | test("an rsa-sha2-512 offer answered with an ssh-rsa (SHA-1) signature is refused", () => { | |
| 134 | const pair = rsaPair(); | |
| 135 | const sha1 = pair.signWith("ssh-rsa", message); | |
| 136 | expect( | |
| 137 | verifySshSignature("rsa-sha2-512", pair.blob, sha1, message).ok, | |
| 138 | ).toBe(false); | |
| 139 | // Even offered as ssh-rsa itself, SHA-1 is not an accepted algorithm. | |
| 140 | expect(verifySshSignature("ssh-rsa", pair.blob, sha1, message).ok).toBe( | |
| 141 | false, | |
| 142 | ); | |
| 143 | }); | |
| 144 | ||
| 145 | test("a sha2-256 signature relabelled sha2-512, or the reverse, is refused", () => { | |
| 146 | const pair = rsaPair(); | |
| 147 | const s256 = pair.signWith("rsa-sha2-256", message); | |
| 148 | expect( | |
| 149 | verifySshSignature("rsa-sha2-512", pair.blob, s256, message).ok, | |
| 150 | ).toBe(false); | |
| 151 | }); | |
| 152 | ||
| 153 | test("an rsa algorithm over an ed25519 key is refused", () => { | |
| 154 | const ed = ed25519Pair(); | |
| 155 | const sig = ed.signWith("rsa-sha2-256", message); | |
| 156 | expect(verifySshSignature("rsa-sha2-256", ed.blob, sig, message).ok).toBe( | |
| 157 | false, | |
| 158 | ); | |
| 159 | }); | |
| 160 | }); | |
| 161 | ||
| 162 | describe("keyAlgorithmMatches", () => { | |
| 163 | test("rsa-sha2-256 and -512 pair with an ssh-rsa blob", () => { | |
| 164 | expect(keyAlgorithmMatches("rsa-sha2-256", "ssh-rsa")).toBe(true); | |
| 165 | expect(keyAlgorithmMatches("rsa-sha2-512", "ssh-rsa")).toBe(true); | |
| 166 | }); | |
| 167 | ||
| 168 | test("identical names match, and nothing else differing does", () => { | |
| 169 | expect(keyAlgorithmMatches("ssh-ed25519", "ssh-ed25519")).toBe(true); | |
| 170 | expect(keyAlgorithmMatches("ssh-rsa", "ssh-rsa")).toBe(true); | |
| 171 | expect(keyAlgorithmMatches("ssh-rsa", "rsa-sha2-256")).toBe(false); | |
| 172 | expect(keyAlgorithmMatches("rsa-sha2-256", "ssh-ed25519")).toBe(false); | |
| 173 | expect(keyAlgorithmMatches("ssh-ed25519", "ssh-rsa")).toBe(false); | |
| 174 | expect(keyAlgorithmMatches("rsa-sha2-256", "rsa-sha2-512")).toBe(false); | |
| 175 | expect(keyAlgorithmMatches("ecdsa-sha2-nistp256", "ssh-rsa")).toBe(false); | |
| 176 | }); | |
| 177 | }); | |
| 178 | ||
| 179 | describe("fingerprintOf", () => { | |
| 180 | test("prints SHA256: and unpadded base64 for a fixed blob", () => { | |
| 181 | // sha256("") is e3b0c442…b855; 32 bytes base64 would end in "=". | |
| 182 | expect(fingerprintOf(new Uint8Array(0))).toBe( | |
| 183 | "SHA256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU", | |
| 184 | ); | |
| 185 | expect(fingerprintOf(new TextEncoder().encode("abc"))).toBe( | |
| 186 | "SHA256:ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0", | |
| 187 | ); | |
| 188 | }); | |
| 189 | }); |
new above
offline
Minor: the test is named "or the reverse", but only sha2-256 offered as sha2-512 is checked. It is also refused by the
sigAlgo !== algoname check, not by the hash. A true relabel would wrap the SHA-256 signature bytes in a blob that saysrsa-sha2-512, which reachescryptoVerifywith the wrong hash. Do it in both directions, or drop "or the reverse" from the name. This can go in a later test pull.