passwd-sso-cli 0.4.50 → 0.4.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/agent-decrypt.js +10 -2
- package/dist/commands/agent.d.ts +6 -1
- package/dist/commands/agent.js +139 -36
- package/dist/commands/env.js +2 -2
- package/dist/commands/export.js +5 -6
- package/dist/commands/get.js +2 -2
- package/dist/commands/list.js +2 -2
- package/dist/commands/run.js +2 -2
- package/dist/commands/totp.js +2 -2
- package/dist/lib/config.js +12 -15
- package/dist/lib/crypto-aad.d.ts +6 -1
- package/dist/lib/crypto-aad.js +8 -2
- package/dist/lib/oauth.js +1 -1
- package/dist/lib/secure-file.d.ts +16 -0
- package/dist/lib/secure-file.js +41 -0
- package/dist/lib/ssh-agent-protocol.d.ts +26 -1
- package/dist/lib/ssh-agent-protocol.js +38 -1
- package/dist/lib/ssh-agent-socket.d.ts +43 -2
- package/dist/lib/ssh-agent-socket.js +160 -63
- package/dist/lib/ssh-confirm.d.ts +26 -0
- package/dist/lib/ssh-confirm.js +46 -0
- package/dist/lib/ssh-key-agent.d.ts +4 -2
- package/dist/lib/ssh-key-agent.js +5 -2
- package/dist/lib/ssh-session-bind.d.ts +77 -0
- package/dist/lib/ssh-session-bind.js +272 -0
- package/dist/lib/ssh-sign-authorizer.d.ts +32 -0
- package/dist/lib/ssh-sign-authorizer.js +61 -0
- package/package.json +2 -2
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH session-bind@openssh.com extension parser and verifier.
|
|
3
|
+
*
|
|
4
|
+
* Parses and cryptographically verifies the session-bind extension payload
|
|
5
|
+
* so the agent can record the host-key fingerprint and forwarding flag for
|
|
6
|
+
* audit metadata without trusting unauthenticated client assertions.
|
|
7
|
+
*
|
|
8
|
+
* Reference: RFC 9987 §4.7 + OpenSSH PROTOCOL.agent
|
|
9
|
+
*/
|
|
10
|
+
import { createPublicKey, createVerify, verify as cryptoVerify, createHash, } from "node:crypto";
|
|
11
|
+
import { readString } from "./ssh-agent-protocol.js";
|
|
12
|
+
// ─── Parsing ──────────────────────────────────────────────────
|
|
13
|
+
/**
|
|
14
|
+
* Parse the payload of a session-bind@openssh.com extension message.
|
|
15
|
+
*
|
|
16
|
+
* The contents (after the extension name string) are:
|
|
17
|
+
* string hostkey (SSH wire-format public key blob)
|
|
18
|
+
* string session-identifier
|
|
19
|
+
* string signature (SSH wire-format signature blob)
|
|
20
|
+
* bool is_forwarding (1 byte: 0x01 = true, 0x00 = false)
|
|
21
|
+
*
|
|
22
|
+
* @param rest Bytes immediately following the extension name string
|
|
23
|
+
*/
|
|
24
|
+
export function parseSessionBind(rest) {
|
|
25
|
+
let offset = 0;
|
|
26
|
+
const { data: hostKeyBlob, nextOffset: o1 } = readString(rest, offset);
|
|
27
|
+
offset = o1;
|
|
28
|
+
const { data: sessionId, nextOffset: o2 } = readString(rest, offset);
|
|
29
|
+
offset = o2;
|
|
30
|
+
const { data: signature, nextOffset: o3 } = readString(rest, offset);
|
|
31
|
+
offset = o3;
|
|
32
|
+
if (offset >= rest.length) {
|
|
33
|
+
throw new Error("session-bind payload too short: missing is_forwarding byte");
|
|
34
|
+
}
|
|
35
|
+
const isForwarding = rest[offset] !== 0;
|
|
36
|
+
return { hostKeyBlob, sessionId, signature, isForwarding };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Convert an SSH wire-format public key blob into a Node.js KeyObject.
|
|
40
|
+
*
|
|
41
|
+
* Supported types:
|
|
42
|
+
* - ssh-ed25519
|
|
43
|
+
* - ssh-rsa
|
|
44
|
+
* - ecdsa-sha2-nistp256 / nistp384 / nistp521
|
|
45
|
+
*
|
|
46
|
+
* Throws an Error for unsupported key types so the caller (verifySessionBind)
|
|
47
|
+
* can catch it and return false.
|
|
48
|
+
*/
|
|
49
|
+
export function sshWirePublicKeyToKeyObject(blob) {
|
|
50
|
+
const { data: keyTypeBuf, nextOffset } = readString(blob, 0);
|
|
51
|
+
const keyType = keyTypeBuf.toString("utf-8");
|
|
52
|
+
switch (keyType) {
|
|
53
|
+
case "ssh-ed25519": {
|
|
54
|
+
const { data: pubPoint } = readString(blob, nextOffset);
|
|
55
|
+
if (pubPoint.length !== 32) {
|
|
56
|
+
throw new Error(`Invalid Ed25519 public key length: ${pubPoint.length}`);
|
|
57
|
+
}
|
|
58
|
+
const key = createPublicKey({
|
|
59
|
+
key: {
|
|
60
|
+
kty: "OKP",
|
|
61
|
+
crv: "Ed25519",
|
|
62
|
+
x: base64url(pubPoint),
|
|
63
|
+
},
|
|
64
|
+
format: "jwk",
|
|
65
|
+
});
|
|
66
|
+
return { key, keyType };
|
|
67
|
+
}
|
|
68
|
+
case "ssh-rsa": {
|
|
69
|
+
// SSH RSA wire format: string(e), string(n) — both big-endian mpints
|
|
70
|
+
const { data: e, nextOffset: o1 } = readString(blob, nextOffset);
|
|
71
|
+
const { data: n } = readString(blob, o1);
|
|
72
|
+
const key = createPublicKey({
|
|
73
|
+
key: {
|
|
74
|
+
kty: "RSA",
|
|
75
|
+
n: base64url(stripLeadingZero(n)),
|
|
76
|
+
e: base64url(stripLeadingZero(e)),
|
|
77
|
+
},
|
|
78
|
+
format: "jwk",
|
|
79
|
+
});
|
|
80
|
+
return { key, keyType };
|
|
81
|
+
}
|
|
82
|
+
case "ecdsa-sha2-nistp256":
|
|
83
|
+
case "ecdsa-sha2-nistp384":
|
|
84
|
+
case "ecdsa-sha2-nistp521": {
|
|
85
|
+
// SSH ECDSA wire format: string(curve-name), string(Q point 0x04||x||y)
|
|
86
|
+
const { data: curveNameBuf, nextOffset: o1 } = readString(blob, nextOffset);
|
|
87
|
+
const curveName = curveNameBuf.toString("utf-8");
|
|
88
|
+
const { data: qPoint } = readString(blob, o1);
|
|
89
|
+
const curveMap = {
|
|
90
|
+
"nistp256": { crv: "P-256", size: 32 },
|
|
91
|
+
"nistp384": { crv: "P-384", size: 48 },
|
|
92
|
+
"nistp521": { crv: "P-521", size: 66 },
|
|
93
|
+
};
|
|
94
|
+
const curve = curveMap[curveName];
|
|
95
|
+
if (!curve) {
|
|
96
|
+
throw new Error(`Unsupported ECDSA curve: ${curveName}`);
|
|
97
|
+
}
|
|
98
|
+
if (qPoint[0] !== 0x04) {
|
|
99
|
+
throw new Error("Only uncompressed ECDSA points are supported");
|
|
100
|
+
}
|
|
101
|
+
const x = qPoint.subarray(1, 1 + curve.size);
|
|
102
|
+
const y = qPoint.subarray(1 + curve.size, 1 + 2 * curve.size);
|
|
103
|
+
const key = createPublicKey({
|
|
104
|
+
key: {
|
|
105
|
+
kty: "EC",
|
|
106
|
+
crv: curve.crv,
|
|
107
|
+
x: base64url(x),
|
|
108
|
+
y: base64url(y),
|
|
109
|
+
},
|
|
110
|
+
format: "jwk",
|
|
111
|
+
});
|
|
112
|
+
return { key, keyType };
|
|
113
|
+
}
|
|
114
|
+
default:
|
|
115
|
+
throw new Error(`Unsupported SSH public key type: ${keyType}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// ─── Signature verification ────────────────────────────────────
|
|
119
|
+
/**
|
|
120
|
+
* Verify that the session-bind signature is valid.
|
|
121
|
+
*
|
|
122
|
+
* The host proves session control by signing the session identifier with the
|
|
123
|
+
* host private key. This binds the agent connection to a specific SSH session
|
|
124
|
+
* and host, preventing forwarded-agent hijack attacks.
|
|
125
|
+
*
|
|
126
|
+
* Algorithm binding (RFC 9987 §3.2): the signature algorithm name embedded in
|
|
127
|
+
* the signature blob must be consistent with the host-key type. Inconsistent
|
|
128
|
+
* algorithm names are rejected to prevent downgrade attacks.
|
|
129
|
+
*
|
|
130
|
+
* Returns false (never throws) on any parse error, unsupported type, or
|
|
131
|
+
* invalid signature.
|
|
132
|
+
*/
|
|
133
|
+
export function verifySessionBind(parsed) {
|
|
134
|
+
try {
|
|
135
|
+
const { key, keyType } = sshWirePublicKeyToKeyObject(parsed.hostKeyBlob);
|
|
136
|
+
// Parse the SSH signature blob: string(algo-name) + string(raw-sig)
|
|
137
|
+
const { data: algoNameBuf, nextOffset } = readString(parsed.signature, 0);
|
|
138
|
+
const algoName = algoNameBuf.toString("utf-8");
|
|
139
|
+
const { data: rawSig } = readString(parsed.signature, nextOffset);
|
|
140
|
+
// Algorithm-binding check: reject mismatched algo/key pairs
|
|
141
|
+
if (!isAlgoConsistentWithKeyType(algoName, keyType)) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
return verifySshSignature(key, keyType, algoName, rawSig, parsed.sessionId);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// Any parse or crypto error → treat as invalid
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// ─── Fingerprint ──────────────────────────────────────────────
|
|
152
|
+
/**
|
|
153
|
+
* Compute the standard OpenSSH SHA256 fingerprint of a public key blob.
|
|
154
|
+
*
|
|
155
|
+
* Format: "SHA256:<base64-no-padding>" (matches `ssh-keygen -l -E sha256` output).
|
|
156
|
+
*/
|
|
157
|
+
export function fingerprintPublicKey(blob) {
|
|
158
|
+
const digest = createHash("sha256").update(blob).digest("base64");
|
|
159
|
+
// Remove trailing "=" padding to match OpenSSH output
|
|
160
|
+
const base64noPad = digest.replace(/=+$/, "");
|
|
161
|
+
return `SHA256:${base64noPad}`;
|
|
162
|
+
}
|
|
163
|
+
// ─── Internal helpers ─────────────────────────────────────────
|
|
164
|
+
/**
|
|
165
|
+
* Check that a signature algorithm name is consistent with the host key type.
|
|
166
|
+
*
|
|
167
|
+
* Prevents an attacker from substituting a weaker algorithm (e.g. ssh-rsa/SHA1)
|
|
168
|
+
* for a key that supports stronger hashes, or from mixing algorithms across
|
|
169
|
+
* key families entirely.
|
|
170
|
+
*/
|
|
171
|
+
function isAlgoConsistentWithKeyType(algoName, keyType) {
|
|
172
|
+
switch (keyType) {
|
|
173
|
+
case "ssh-ed25519":
|
|
174
|
+
return algoName === "ssh-ed25519";
|
|
175
|
+
case "ssh-rsa":
|
|
176
|
+
// RSA host key: require SHA-2 signatures only. Modern OpenSSH (7.8+) emits
|
|
177
|
+
// rsa-sha2-256/512 for session-bind even with ssh-rsa host keys; the legacy
|
|
178
|
+
// ssh-rsa (SHA-1) signature algorithm is rejected (no SHA-1 in the trust path).
|
|
179
|
+
return algoName === "rsa-sha2-256" || algoName === "rsa-sha2-512";
|
|
180
|
+
case "ecdsa-sha2-nistp256":
|
|
181
|
+
return algoName === "ecdsa-sha2-nistp256";
|
|
182
|
+
case "ecdsa-sha2-nistp384":
|
|
183
|
+
return algoName === "ecdsa-sha2-nistp384";
|
|
184
|
+
case "ecdsa-sha2-nistp521":
|
|
185
|
+
return algoName === "ecdsa-sha2-nistp521";
|
|
186
|
+
default:
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Dispatch to the correct crypto.verify call based on algorithm.
|
|
192
|
+
*/
|
|
193
|
+
function verifySshSignature(key, keyType, algoName, rawSig, data) {
|
|
194
|
+
switch (keyType) {
|
|
195
|
+
case "ssh-ed25519":
|
|
196
|
+
// Ed25519: no hash (null), raw 64-byte signature
|
|
197
|
+
return cryptoVerify(null, data, key, rawSig);
|
|
198
|
+
case "ssh-rsa": {
|
|
199
|
+
const hash = algoName === "rsa-sha2-512" ? "sha512" : "sha256";
|
|
200
|
+
const verifier = createVerify(hash);
|
|
201
|
+
verifier.update(data);
|
|
202
|
+
return verifier.verify(key, rawSig);
|
|
203
|
+
}
|
|
204
|
+
case "ecdsa-sha2-nistp256":
|
|
205
|
+
case "ecdsa-sha2-nistp384":
|
|
206
|
+
case "ecdsa-sha2-nistp521": {
|
|
207
|
+
const hashMap = {
|
|
208
|
+
"ecdsa-sha2-nistp256": "sha256",
|
|
209
|
+
"ecdsa-sha2-nistp384": "sha384",
|
|
210
|
+
"ecdsa-sha2-nistp521": "sha512",
|
|
211
|
+
};
|
|
212
|
+
const hash = hashMap[keyType];
|
|
213
|
+
// SSH ECDSA raw sig: string(r) || string(s) — convert to DER for Node crypto
|
|
214
|
+
const derSig = sshEcdsaToDer(rawSig);
|
|
215
|
+
const verifier = createVerify(hash);
|
|
216
|
+
verifier.update(data);
|
|
217
|
+
return verifier.verify(key, derSig);
|
|
218
|
+
}
|
|
219
|
+
default:
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Convert an SSH ECDSA signature (string(r) || string(s)) to DER format.
|
|
225
|
+
*
|
|
226
|
+
* SSH format: uint32(rLen) || r || uint32(sLen) || s
|
|
227
|
+
* DER format: SEQUENCE { INTEGER r, INTEGER s }
|
|
228
|
+
*
|
|
229
|
+
* This is the inverse of derToSshEcdsa in ssh-key-agent.ts.
|
|
230
|
+
*/
|
|
231
|
+
function sshEcdsaToDer(sshSig) {
|
|
232
|
+
const { data: r, nextOffset } = readString(sshSig, 0);
|
|
233
|
+
const { data: s } = readString(sshSig, nextOffset);
|
|
234
|
+
// DER definite-length encoding: short form (<128) is one byte; long form
|
|
235
|
+
// (>=128) is 0x80|n followed by n big-endian length bytes. P-521 signatures
|
|
236
|
+
// push the SEQUENCE length past 127, so short-form-only encoding is invalid.
|
|
237
|
+
const encodeLen = (len) => {
|
|
238
|
+
if (len < 0x80)
|
|
239
|
+
return Buffer.from([len]);
|
|
240
|
+
const bytes = [];
|
|
241
|
+
let v = len;
|
|
242
|
+
while (v > 0) {
|
|
243
|
+
bytes.unshift(v & 0xff);
|
|
244
|
+
v >>= 8;
|
|
245
|
+
}
|
|
246
|
+
return Buffer.from([0x80 | bytes.length, ...bytes]);
|
|
247
|
+
};
|
|
248
|
+
// DER-encode each integer: 0x02 + length + value (with leading 0x00 if MSB set)
|
|
249
|
+
const encodeInt = (n) => {
|
|
250
|
+
const needsPad = n[0] !== undefined && (n[0] & 0x80) !== 0;
|
|
251
|
+
const value = needsPad ? Buffer.concat([Buffer.from([0x00]), n]) : n;
|
|
252
|
+
return Buffer.concat([Buffer.from([0x02]), encodeLen(value.length), value]);
|
|
253
|
+
};
|
|
254
|
+
const rDer = encodeInt(r);
|
|
255
|
+
const sDer = encodeInt(s);
|
|
256
|
+
const seq = Buffer.concat([rDer, sDer]);
|
|
257
|
+
return Buffer.concat([Buffer.from([0x30]), encodeLen(seq.length), seq]);
|
|
258
|
+
}
|
|
259
|
+
function base64url(buf) {
|
|
260
|
+
return Buffer.from(buf)
|
|
261
|
+
.toString("base64")
|
|
262
|
+
.replace(/\+/g, "-")
|
|
263
|
+
.replace(/\//g, "_")
|
|
264
|
+
.replace(/=+$/, "");
|
|
265
|
+
}
|
|
266
|
+
function stripLeadingZero(buf) {
|
|
267
|
+
let i = 0;
|
|
268
|
+
while (i < buf.length - 1 && buf[i] === 0)
|
|
269
|
+
i++;
|
|
270
|
+
return i > 0 ? buf.subarray(i) : buf;
|
|
271
|
+
}
|
|
272
|
+
//# sourceMappingURL=ssh-session-bind.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-signature server authorization for the SSH agent.
|
|
3
|
+
*
|
|
4
|
+
* Before each signature, the agent POSTs to /api/vault/ssh/sign-authorize to
|
|
5
|
+
* authorize the operation and emit an audit event server-side. The result is
|
|
6
|
+
* never cached — one authorize call per signature ensures immediate revocation
|
|
7
|
+
* and a complete audit trail.
|
|
8
|
+
*
|
|
9
|
+
* Threat-model note: this is an honest-agent audit/revocation control. A
|
|
10
|
+
* compromised agent process that already holds the decrypted private key can
|
|
11
|
+
* always sign locally; per-sign authorize cannot prevent that. The value is
|
|
12
|
+
* audit completeness and immediate policy enforcement for an honest agent.
|
|
13
|
+
*/
|
|
14
|
+
import type { SessionBinding } from "./ssh-session-bind.js";
|
|
15
|
+
/**
|
|
16
|
+
* Authorize a single SSH signing operation against the server.
|
|
17
|
+
*
|
|
18
|
+
* Returns true only on HTTP 200 with `authorized === true`.
|
|
19
|
+
* Any other status, network error, or malformed response → false (fail-closed).
|
|
20
|
+
*/
|
|
21
|
+
export declare function authorizeSign(args: {
|
|
22
|
+
keyId: string;
|
|
23
|
+
fingerprint: string;
|
|
24
|
+
binding: SessionBinding | null;
|
|
25
|
+
}): Promise<boolean>;
|
|
26
|
+
/**
|
|
27
|
+
* Reset the one-time scope hint guard.
|
|
28
|
+
* Exposed for testing only — do not call from production code.
|
|
29
|
+
*
|
|
30
|
+
* @internal
|
|
31
|
+
*/
|
|
32
|
+
export declare function _resetScopeHintForTest(): void;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-signature server authorization for the SSH agent.
|
|
3
|
+
*
|
|
4
|
+
* Before each signature, the agent POSTs to /api/vault/ssh/sign-authorize to
|
|
5
|
+
* authorize the operation and emit an audit event server-side. The result is
|
|
6
|
+
* never cached — one authorize call per signature ensures immediate revocation
|
|
7
|
+
* and a complete audit trail.
|
|
8
|
+
*
|
|
9
|
+
* Threat-model note: this is an honest-agent audit/revocation control. A
|
|
10
|
+
* compromised agent process that already holds the decrypted private key can
|
|
11
|
+
* always sign locally; per-sign authorize cannot prevent that. The value is
|
|
12
|
+
* audit completeness and immediate policy enforcement for an honest agent.
|
|
13
|
+
*/
|
|
14
|
+
import { apiRequest } from "./api-client.js";
|
|
15
|
+
import * as output from "./output.js";
|
|
16
|
+
// One-time hint guard: print "re-run `passwd-sso login`" at most once per run.
|
|
17
|
+
let scopeHintEmitted = false;
|
|
18
|
+
/**
|
|
19
|
+
* Authorize a single SSH signing operation against the server.
|
|
20
|
+
*
|
|
21
|
+
* Returns true only on HTTP 200 with `authorized === true`.
|
|
22
|
+
* Any other status, network error, or malformed response → false (fail-closed).
|
|
23
|
+
*/
|
|
24
|
+
export async function authorizeSign(args) {
|
|
25
|
+
const { keyId, fingerprint, binding } = args;
|
|
26
|
+
try {
|
|
27
|
+
const body = { keyId, fingerprint };
|
|
28
|
+
if (binding) {
|
|
29
|
+
body.host = {
|
|
30
|
+
hostKeyFingerprint: binding.hostKeyFingerprint,
|
|
31
|
+
forwarded: binding.forwarded,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const res = await apiRequest("/api/vault/ssh/sign-authorize", { method: "POST", body });
|
|
35
|
+
if (res.ok && res.status === 200 && res.data.authorized === true) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
// On scope-deny (401/403 with reason "unauthorized"), print a one-time hint.
|
|
39
|
+
if ((res.status === 401 || res.status === 403) && res.data.reason === "unauthorized") {
|
|
40
|
+
if (!scopeHintEmitted) {
|
|
41
|
+
scopeHintEmitted = true;
|
|
42
|
+
output.warn("Re-run `passwd-sso login` to grant SSH signing (ssh:sign scope).");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
output.warn(`SSH sign authorize failed: ${err instanceof Error ? err.message : "network error"}`);
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Reset the one-time scope hint guard.
|
|
54
|
+
* Exposed for testing only — do not call from production code.
|
|
55
|
+
*
|
|
56
|
+
* @internal
|
|
57
|
+
*/
|
|
58
|
+
export function _resetScopeHintForTest() {
|
|
59
|
+
scopeHintEmitted = false;
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=ssh-sign-authorizer.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "passwd-sso-cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.56",
|
|
4
4
|
"description": "CLI for passwd-sso password manager",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@types/node": "^22.15.3",
|
|
49
49
|
"tsx": "^4.19.4",
|
|
50
50
|
"typescript": "^5.9.0-beta",
|
|
51
|
-
"vitest": "^4.
|
|
51
|
+
"vitest": "^4.1.8"
|
|
52
52
|
},
|
|
53
53
|
"overrides": {
|
|
54
54
|
"postcss": ">=8.5.10"
|