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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/6902-a-test-pins-ssh-signature-checks-another → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/6902-a-test-pins-ssh-signature-checks-another was merged into main as 134ed53.

Review

Sign in to review this change.
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", () => {

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 !== algo name check, not by the hash. A true relabel would wrap the SHA-256 signature bytes in a blob that says rsa-sha2-512, which reaches cryptoVerify with the wrong hash. Do it in both directions, or drop "or the reverse" from the name. This can go in a later test pull.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs