diff --git a/README.md b/README.md
index a4b859f..b309529 100644
--- a/README.md
+++ b/README.md
@@ -116,7 +116,7 @@ Legend:
| public-key | `ecdsa-sha2-nistp256` | ✔✔
| public-key | `ecdsa-sha2-nistp384` | ✔✔
| public-key | `ecdsa-sha2-nistp521` | ✔
-| public-key | `ssh-ed25519` | ?? [1]
+| public-key | `ssh-ed25519` | ✔ [4]
| public-key | `*-cert-v01@openssh.com` | ?? [2]
| | |
| cipher | `aes256-cbc` | ✔✔ [3]
@@ -137,11 +137,15 @@ Legend:
[1] May require use of 3rd-party libs, though Curve25519 APIs are under
consideration for [.NET](https://github.com/dotnet/runtime/issues/14741) and
-[web crypto](https://github.com/w3c/webcrypto/issues/233).
+[web crypto](https://github.com/w3c/webcrypto/issues/233).
+Ed25519 is now supported in the TS library (see [4]).
[2] OpenSSH certificate support should be possible with some work.
[3] AES-CBC is not supported in browsers due to a [limitation](
https://github.com/w3c/webcrypto/issues/73) of the web crypto API. AES-CTR or
-AES-GCM works fine.
+AES-GCM works fine.
+[4] Ed25519 is supported in the TypeScript library only (Node.js and browsers
+via web crypto). Not yet enabled in the default session configuration; add
+`SshAlgorithms.publicKey.ed25519` to enable it.
There is no plan to have built-in support for older algorithms known to be
insecure (for example SHA-1), though in some cases these can be easily added by
@@ -156,10 +160,10 @@ or [src/ts/ssh-keys/README.md](src/ts/ssh-keys/README.md).
| Key Format | Key Algorithm | Password Protection | Format Description |
| -------------------- | ------------- | ------------------- | ------------------ |
-| SSH public key | RSA
ECDSA | N/A | Single line key algorithm name, base64-encoded key bytes, and optional comment. Files conventionally end with `.pub`.
+| SSH public key | RSA
ECDSA
Ed25519 | N/A | Single line key algorithm name, base64-encoded key bytes, and optional comment. Files conventionally end with `.pub`.
| PKCS#1 | RSA | _import only_ | Starts with one of:
`-----BEGIN RSA PUBLIC KEY-----`
`-----BEGIN RSA PRIVATE KEY-----`
| SEC1 | ECDSA | _import only_ | Starts with:
`-----BEGIN EC PRIVATE KEY-----`
-| PKCS#8 | RSA
ECDSA | ✔ | Starts with one of:
`-----BEGIN PUBLIC KEY-----`
`-----BEGIN PRIVATE KEY-----`
`-----BEGIN ENCRYPTED PRIVATE KEY-----`
+| PKCS#8 | RSA
ECDSA
Ed25519 | ✔ | Starts with one of:
`-----BEGIN PUBLIC KEY-----`
`-----BEGIN PRIVATE KEY-----`
`-----BEGIN ENCRYPTED PRIVATE KEY-----`
| SSH2
_C# only_ | RSA | ✔ | Starts with one of:
`---- BEGIN SSH2 PUBLIC KEY ----`
`---- BEGIN SSH2 ENCRYPTED PRIVATE KEY ----`
| OpenSSH
_C# only_ | RSA
ECDSA | ✔ | Starts with one of:
`-----BEGIN OPENSSH PUBLIC KEY-----`
`-----BEGIN OPENSSH PRIVATE KEY-----`
| JWK
_TS only_ | RSA
ECDSA | N/A | JSON with key algorithm name and parameters
@@ -176,3 +180,4 @@ The following RFCs define the SSH protocol:
- [RFC 5647 - AES GCM for the SSH Protocol](https://tools.ietf.org/html/rfc5647)
- [RFC 5656 - EC Algorithm Integration in SSH](https://tools.ietf.org/html/rfc5656)
- [RFC 8308 - SSH Extension Negotiation](https://tools.ietf.org/html/rfc8308)
+ - [RFC 8709 - Ed25519 and Ed448 Public Key Algorithms for SSH](https://tools.ietf.org/html/rfc8709)
diff --git a/src/ts/ssh-keys/pkcs8KeyFormatter.ts b/src/ts/ssh-keys/pkcs8KeyFormatter.ts
index 6cf6e5e..ee7d9af 100644
--- a/src/ts/ssh-keys/pkcs8KeyFormatter.ts
+++ b/src/ts/ssh-keys/pkcs8KeyFormatter.ts
@@ -16,6 +16,8 @@ import {
Random,
ECParameters,
ECDsa,
+ Ed25519,
+ EdDSAParameters,
} from '@microsoft/dev-tunnels-ssh';
import { KeyFormatter, useWebCrypto } from './keyFormatter';
import { KeyData } from './keyData';
@@ -23,6 +25,7 @@ import { KeyData } from './keyData';
const enum Oids {
rsa = '1.2.840.113549.1.1.1',
ec = '1.2.840.10045.2.1',
+ ed25519 = '1.3.101.112',
pkcs5PBKDF2 = '1.2.840.113549.1.5.12',
pkcs5PBES2 = '1.2.840.113549.1.5.13',
hmacWithSHA256 = '1.2.840.113549.2.9',
@@ -54,10 +57,12 @@ export class Pkcs8KeyFormatter implements KeyFormatter {
public constructor() {
this.importers.set(Oids.rsa, Pkcs8KeyFormatter.importRsaKey);
this.importers.set(Oids.ec, Pkcs8KeyFormatter.importECKey);
+ this.importers.set(Oids.ed25519, Pkcs8KeyFormatter.importEd25519Key);
this.exporters.set(Rsa.keyAlgorithmName, Pkcs8KeyFormatter.exportRsaKey);
this.exporters.set(ECDsa.ecdsaSha2Nistp256, Pkcs8KeyFormatter.exportECKey);
this.exporters.set(ECDsa.ecdsaSha2Nistp384, Pkcs8KeyFormatter.exportECKey);
this.exporters.set(ECDsa.ecdsaSha2Nistp521, Pkcs8KeyFormatter.exportECKey);
+ this.exporters.set(Ed25519.keyAlgorithmName, Pkcs8KeyFormatter.exportEd25519Key);
}
/** Mapping from public key algorithm OID to import handler for that algorithm. */
@@ -376,6 +381,86 @@ export class Pkcs8KeyFormatter implements KeyFormatter {
}
}
+ private static async importEd25519Key(
+ keyBytes: Buffer,
+ oidReader: DerReader,
+ includePrivate: boolean,
+ ): Promise {
+ let publicKeyBytes: Buffer;
+ let privateKeyBytes: Buffer | undefined;
+
+ if (includePrivate) {
+ // The private key is wrapped in an OCTET STRING containing the raw 32-byte key.
+ const keyReader = new DerReader(keyBytes);
+ privateKeyBytes = keyReader.readOctetString();
+
+ // For PKCS#8 Ed25519 private keys, the public key may not be present
+ // in the inner structure. We need to derive it or it may be appended.
+ // Some encoders append the public key after the private key bytes.
+ if (privateKeyBytes.length === 64) {
+ // Some formats store private + public concatenated.
+ publicKeyBytes = privateKeyBytes.slice(32);
+ privateKeyBytes = privateKeyBytes.slice(0, 32);
+ } else if (privateKeyBytes.length === 32) {
+ // Generate public key from private key by importing and re-exporting.
+ // For now, create a temporary key pair to derive the public key.
+ const tempKeyPair = new Ed25519.KeyPair();
+ const tempParams: EdDSAParameters = {
+ curve: { name: 'Ed25519' },
+ publicKey: Buffer.alloc(32), // placeholder
+ privateKey: privateKeyBytes,
+ };
+ // We can't derive the public key without importing. Import with a
+ // placeholder and let the key pair handle it via generate + re-import.
+ // Instead, use the raw PKCS#8 bytes directly.
+ await tempKeyPair.importParameters(tempParams);
+ const exported = await tempKeyPair.exportParameters();
+ publicKeyBytes = exported.publicKey;
+ } else {
+ throw new Error(`Unexpected Ed25519 private key length: ${privateKeyBytes.length}`);
+ }
+ } else {
+ publicKeyBytes = keyBytes;
+ }
+
+ if (publicKeyBytes.length !== 32) {
+ throw new Error(`Unexpected Ed25519 public key length: ${publicKeyBytes.length}`);
+ }
+
+ const parameters: EdDSAParameters = {
+ curve: { name: 'Ed25519' },
+ publicKey: publicKeyBytes,
+ privateKey: privateKeyBytes,
+ };
+
+ const keyPair = new Ed25519.KeyPair();
+ await keyPair.importParameters(parameters);
+ return keyPair;
+ }
+
+ private static async exportEd25519Key(
+ keyPair: KeyPair,
+ oidWriter: DerWriter,
+ includePrivate: boolean,
+ ): Promise {
+ const parameters = await keyPair.exportParameters();
+
+ oidWriter.writeObjectIdentifier(Oids.ed25519);
+
+ if (includePrivate) {
+ if (!parameters.privateKey) {
+ throw new Error('Missing private key parameters.');
+ }
+
+ // Wrap the raw private key in an OCTET STRING.
+ const keyWriter = new DerWriter(Buffer.alloc(64));
+ keyWriter.writeOctetString(parameters.privateKey);
+ return keyWriter.toBuffer();
+ } else {
+ return parameters.publicKey;
+ }
+ }
+
private static async decryptPrivate(keyData: KeyData, passphrase: string): Promise {
let reader = new DerReader(keyData.data);
const innerReader = reader.readSequence();
diff --git a/src/ts/ssh-keys/publicKeyFormatter.ts b/src/ts/ssh-keys/publicKeyFormatter.ts
index 5ffa362..9602bf8 100644
--- a/src/ts/ssh-keys/publicKeyFormatter.ts
+++ b/src/ts/ssh-keys/publicKeyFormatter.ts
@@ -2,7 +2,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
//
-import { SshAlgorithms, KeyPair, Rsa, ECDsa, SshDataReader } from '@microsoft/dev-tunnels-ssh';
+import { SshAlgorithms, KeyPair, Rsa, ECDsa, Ed25519, SshDataReader } from '@microsoft/dev-tunnels-ssh';
import { KeyFormatter } from './keyFormatter';
import { KeyData } from './keyData';
@@ -31,6 +31,8 @@ export class PublicKeyFormatter implements KeyFormatter {
keyPair = SshAlgorithms.publicKey.ecdsaSha2Nistp384!.createKeyPair();
} else if (keyData.keyType === ECDsa.ecdsaSha2Nistp521) {
keyPair = SshAlgorithms.publicKey.ecdsaSha2Nistp521!.createKeyPair();
+ } else if (keyData.keyType === Ed25519.keyAlgorithmName) {
+ keyPair = SshAlgorithms.publicKey.ed25519!.createKeyPair();
}
if (keyPair) {
diff --git a/src/ts/ssh/algorithms/node/nodeEd25519.ts b/src/ts/ssh/algorithms/node/nodeEd25519.ts
new file mode 100644
index 0000000..1f837b8
--- /dev/null
+++ b/src/ts/ssh/algorithms/node/nodeEd25519.ts
@@ -0,0 +1,241 @@
+//
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//
+
+import * as crypto from 'crypto';
+import { Buffer } from 'buffer';
+import { PublicKeyAlgorithm, KeyPair, EdDSAParameters } from '../publicKeyAlgorithm';
+import { Signer, Verifier } from '../hmacAlgorithm';
+import { SshDataReader, SshDataWriter } from '../../io/sshData';
+
+const ed25519KeySizeInBytes = 32;
+const ed25519SignatureSizeInBytes = 64;
+
+// Ed25519 OID: 1.3.101.112
+const ed25519Oid = Buffer.from([0x06, 0x03, 0x2b, 0x65, 0x70]);
+
+// PKCS#8 prefix for Ed25519 private key (wraps a 32-byte raw key).
+const pkcs8PrivatePrefix = Buffer.from([
+ 0x30, 0x2e, // SEQUENCE (46 bytes)
+ 0x02, 0x01, 0x00, // INTEGER 0
+ 0x30, 0x05, // SEQUENCE (5 bytes)
+ 0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112
+ 0x04, 0x22, // OCTET STRING (34 bytes)
+ 0x04, 0x20, // OCTET STRING (32 bytes)
+]);
+
+// SPKI prefix for Ed25519 public key (wraps a 32-byte raw key).
+const spkiPublicPrefix = Buffer.from([
+ 0x30, 0x2a, // SEQUENCE (42 bytes)
+ 0x30, 0x05, // SEQUENCE (5 bytes)
+ 0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112
+ 0x03, 0x21, // BIT STRING (33 bytes)
+ 0x00, // no unused bits
+]);
+
+class NodeEd25519KeyPair implements KeyPair {
+ /* @internal */
+ public publicKey?: crypto.KeyObject;
+
+ /* @internal */
+ public privateKey?: crypto.KeyObject;
+
+ public constructor() {}
+
+ public get hasPublicKey() {
+ return !!this.publicKey;
+ }
+ public get hasPrivateKey() {
+ return !!this.privateKey;
+ }
+
+ public comment: string | null = null;
+
+ public get keyAlgorithmName(): string {
+ return NodeEd25519.keyAlgorithmName;
+ }
+
+ public async generate(): Promise {
+ const keyPair = await new Promise<{ publicKey: crypto.KeyObject; privateKey: crypto.KeyObject }>(
+ (resolve, reject) => {
+ try {
+ crypto.generateKeyPair('ed25519', {}, (err, publicKey, privateKey) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve({ publicKey, privateKey });
+ }
+ });
+ } catch (err) {
+ reject(err);
+ }
+ },
+ );
+ this.publicKey = keyPair.publicKey;
+ this.privateKey = keyPair.privateKey;
+ }
+
+ public async setPublicKeyBytes(keyBytes: Buffer): Promise {
+ if (!keyBytes) {
+ throw new TypeError('Buffer is required.');
+ }
+
+ // Read public key in SSH format.
+ const reader = new SshDataReader(keyBytes);
+ const algorithmName = reader.readString('ascii');
+ if (algorithmName !== NodeEd25519.keyAlgorithmName) {
+ throw new Error(`Invalid Ed25519 key algorithm: ${algorithmName}`);
+ }
+
+ const rawPublicKey = reader.readBinary();
+ if (rawPublicKey.length !== ed25519KeySizeInBytes) {
+ throw new Error(`Unexpected Ed25519 public key length: ${rawPublicKey.length}`);
+ }
+
+ const spki = Buffer.concat([spkiPublicPrefix, rawPublicKey]);
+ this.publicKey = crypto.createPublicKey({
+ key: spki,
+ type: 'spki',
+ format: 'der',
+ });
+ }
+
+ public async getPublicKeyBytes(algorithmName?: string): Promise {
+ if (!this.publicKey) {
+ return null;
+ }
+
+ const spki = this.publicKey.export({ type: 'spki', format: 'der' });
+ // Extract the raw 32-byte public key from the SPKI structure.
+ const rawPublicKey = spki.slice(spki.length - ed25519KeySizeInBytes);
+
+ // Write public key in SSH format.
+ algorithmName = algorithmName || NodeEd25519.keyAlgorithmName;
+ const keyBuffer = Buffer.alloc(algorithmName.length + rawPublicKey.length + 8);
+ const keyWriter = new SshDataWriter(keyBuffer);
+ keyWriter.writeString(algorithmName, 'ascii');
+ keyWriter.writeBinary(rawPublicKey);
+
+ return keyWriter.toBuffer();
+ }
+
+ public async importParameters(parameters: EdDSAParameters): Promise {
+ if (!parameters.publicKey) throw new TypeError('Public key bytes are required.');
+ if (parameters.publicKey.length !== ed25519KeySizeInBytes) {
+ throw new Error(`Unexpected Ed25519 public key length: ${parameters.publicKey.length}`);
+ }
+
+ const spki = Buffer.concat([spkiPublicPrefix, parameters.publicKey]);
+ this.publicKey = crypto.createPublicKey({
+ key: spki,
+ type: 'spki',
+ format: 'der',
+ });
+
+ if (parameters.privateKey) {
+ const pkcs8 = Buffer.concat([pkcs8PrivatePrefix, parameters.privateKey]);
+ this.privateKey = crypto.createPrivateKey({
+ key: pkcs8,
+ type: 'pkcs8',
+ format: 'der',
+ });
+ } else {
+ this.privateKey = undefined;
+ }
+ }
+
+ public async exportParameters(): Promise {
+ if (!this.publicKey) {
+ throw new Error('Key not present.');
+ }
+
+ const spki = this.publicKey.export({ type: 'spki', format: 'der' });
+ const rawPublicKey = spki.slice(spki.length - ed25519KeySizeInBytes);
+
+ const parameters: EdDSAParameters = {
+ curve: { name: 'Ed25519' },
+ publicKey: rawPublicKey,
+ };
+
+ if (this.privateKey) {
+ const pkcs8 = this.privateKey.export({ type: 'pkcs8', format: 'der' });
+ parameters.privateKey = pkcs8.slice(pkcs8.length - ed25519KeySizeInBytes);
+ }
+
+ return parameters;
+ }
+
+ public dispose(): void {}
+}
+
+export class NodeEd25519 extends PublicKeyAlgorithm {
+ public static readonly keyAlgorithmName = 'ssh-ed25519';
+
+ public constructor() {
+ super(
+ NodeEd25519.keyAlgorithmName,
+ NodeEd25519.keyAlgorithmName,
+ '', // Ed25519 has a built-in hash (SHA-512); no separate hash algorithm.
+ );
+ }
+
+ public createKeyPair(): KeyPair {
+ return new NodeEd25519KeyPair();
+ }
+
+ public async generateKeyPair(): Promise {
+ const keyPair = new NodeEd25519KeyPair();
+ await keyPair.generate();
+ return keyPair;
+ }
+
+ public createSigner(keyPair: KeyPair): Signer {
+ if (!(keyPair instanceof NodeEd25519KeyPair)) {
+ throw new TypeError('Ed25519 key pair object expected.');
+ }
+
+ return new NodeEd25519SignerVerifier(keyPair);
+ }
+
+ public createVerifier(keyPair: KeyPair): Verifier {
+ if (!(keyPair instanceof NodeEd25519KeyPair)) {
+ throw new TypeError('Ed25519 key pair object expected.');
+ }
+
+ return new NodeEd25519SignerVerifier(keyPair);
+ }
+
+ public static readonly KeyPair = NodeEd25519KeyPair;
+}
+
+class NodeEd25519SignerVerifier implements Signer, Verifier {
+ public constructor(private readonly keyPair: NodeEd25519KeyPair) {}
+
+ public get digestLength(): number {
+ return ed25519SignatureSizeInBytes;
+ }
+
+ public async sign(data: Buffer): Promise {
+ if (!this.keyPair.privateKey) {
+ throw new Error('Private key not set.');
+ }
+
+ return crypto.sign(null, data, this.keyPair.privateKey);
+ }
+
+ public async verify(data: Buffer, signature: Buffer): Promise {
+ if (!this.keyPair.publicKey) {
+ throw new Error('Public key not set.');
+ }
+
+ return crypto.verify(null, data, this.keyPair.publicKey, signature);
+ }
+
+ public dispose(): void {}
+}
+
+// eslint-disable-next-line no-redeclare
+export namespace NodeEd25519 {
+ // eslint-disable-next-line no-shadow, @typescript-eslint/no-shadow
+ export type KeyPair = NodeEd25519KeyPair;
+}
diff --git a/src/ts/ssh/algorithms/publicKeyAlgorithm.ts b/src/ts/ssh/algorithms/publicKeyAlgorithm.ts
index 88d5abb..5682286 100644
--- a/src/ts/ssh/algorithms/publicKeyAlgorithm.ts
+++ b/src/ts/ssh/algorithms/publicKeyAlgorithm.ts
@@ -76,6 +76,12 @@ export interface ECParameters extends KeyPairParameters {
d?: BigInt;
}
+export interface EdDSAParameters extends KeyPairParameters {
+ curve: { name: string };
+ publicKey: Buffer;
+ privateKey?: Buffer;
+}
+
/**
* Given a public key, provides the corresponding private key.
*/
diff --git a/src/ts/ssh/algorithms/sshAlgorithms.ts b/src/ts/ssh/algorithms/sshAlgorithms.ts
index 7c3e5da..a873138 100644
--- a/src/ts/ssh/algorithms/sshAlgorithms.ts
+++ b/src/ts/ssh/algorithms/sshAlgorithms.ts
@@ -4,7 +4,7 @@
import { SshAlgorithm } from './sshAlgorithm';
import { KeyExchangeAlgorithm, KeyExchange } from './keyExchangeAlgorithm';
-import { PublicKeyAlgorithm, KeyPair, RsaParameters, ECParameters } from './publicKeyAlgorithm';
+import { PublicKeyAlgorithm, KeyPair, RsaParameters, ECParameters, EdDSAParameters } from './publicKeyAlgorithm';
import { EncryptionAlgorithm, Cipher } from './encryptionAlgorithm';
import {
HmacAlgorithm,
@@ -33,6 +33,7 @@ export {
CompressionAlgorithm,
RsaParameters,
ECParameters,
+ EdDSAParameters,
};
// Swap imports to node crypto implementations when web crypto is not available.
@@ -44,6 +45,7 @@ const useWebCrypto = typeof self === 'object' && !!(typeof crypto === 'object' &
import { WebDiffieHellman, WebECDiffieHellman } from './web/webKeyExchange';
import { WebRsa } from './web/webRsa';
import { WebECDsa } from './web/webECDsa';
+import { WebEd25519 } from './web/webEd25519';
import { WebEncryption } from './web/webEncryption';
import { WebHmac } from './web/webHmac';
import { WebRandom } from './web/webRandom';
@@ -61,6 +63,9 @@ const ECDiffieHellman: typeof WebECDiffieHellman = useWebCrypto
: require('./node/nodeKeyExchange').NodeECDiffieHellman;
const Rsa: typeof WebRsa = useWebCrypto ? WebRsa : require('./node/nodeRsa').NodeRsa;
const ECDsa: typeof WebECDsa = useWebCrypto ? WebECDsa : require('./node/nodeECDsa').NodeECDsa;
+const Ed25519: typeof WebEd25519 = useWebCrypto
+ ? WebEd25519
+ : require('./node/nodeEd25519').NodeEd25519;
const Encryption: typeof WebEncryption = useWebCrypto
? WebEncryption
: require('./node/nodeEncryption').NodeEncryption;
@@ -81,7 +86,13 @@ namespace ECDsa {
export type KeyPair = WebECDsa.KeyPair;
}
-export { Rsa, ECDsa, Encryption };
+// eslint-disable-next-line no-redeclare
+namespace Ed25519 {
+ // eslint-disable-next-line no-shadow,@typescript-eslint/no-shadow
+ export type KeyPair = WebEd25519.KeyPair;
+}
+
+export { Rsa, ECDsa, Ed25519, Encryption };
export class SshAlgorithms {
public static keyExchange: { [id: string]: KeyExchangeAlgorithm | null } = {
@@ -100,6 +111,7 @@ export class SshAlgorithms {
ecdsaSha2Nistp256: new ECDsa('ecdsa-sha2-nistp256', 'SHA2-256'),
ecdsaSha2Nistp384: new ECDsa('ecdsa-sha2-nistp384', 'SHA2-384'),
ecdsaSha2Nistp521: new ECDsa('ecdsa-sha2-nistp521', 'SHA2-512'),
+ ed25519: new Ed25519(),
};
public static encryption: { [id: string]: EncryptionAlgorithm | null } = {
diff --git a/src/ts/ssh/algorithms/web/webEd25519.ts b/src/ts/ssh/algorithms/web/webEd25519.ts
new file mode 100644
index 0000000..7b7666b
--- /dev/null
+++ b/src/ts/ssh/algorithms/web/webEd25519.ts
@@ -0,0 +1,274 @@
+//
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//
+
+import { Buffer } from 'buffer';
+import { PublicKeyAlgorithm, KeyPair, EdDSAParameters } from '../publicKeyAlgorithm';
+import { SshDataReader, SshDataWriter } from '../../io/sshData';
+import { Signer, Verifier } from '../hmacAlgorithm';
+
+const ed25519KeySizeInBytes = 32;
+const ed25519SignatureSizeInBytes = 64;
+
+class WebEd25519KeyPair implements KeyPair {
+ /* @internal */
+ public publicKey?: CryptoKey;
+
+ /* @internal */
+ public privateKey?: CryptoKey;
+
+ public constructor() {}
+
+ public get hasPublicKey() {
+ return !!this.publicKey;
+ }
+ public get hasPrivateKey() {
+ return !!this.privateKey;
+ }
+
+ public comment: string | null = null;
+
+ public get keyAlgorithmName(): string {
+ return WebEd25519.keyAlgorithmName;
+ }
+
+ public async generate(): Promise {
+ try {
+ const keyPair = (
+ await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify'])
+ );
+ this.publicKey = keyPair.publicKey;
+ this.privateKey = keyPair.privateKey;
+ } catch (e) {
+ throw new Error('Failed to generate Ed25519 key pair: ' + e);
+ }
+ }
+
+ public async setPublicKeyBytes(keyBytes: Buffer): Promise {
+ if (!keyBytes) {
+ throw new TypeError('Buffer is required.');
+ }
+
+ // Read public key in SSH format.
+ const reader = new SshDataReader(keyBytes);
+ const algorithmName = reader.readString('ascii');
+ if (algorithmName !== WebEd25519.keyAlgorithmName) {
+ throw new Error(`Invalid Ed25519 key algorithm: ${algorithmName}`);
+ }
+
+ const rawPublicKey = reader.readBinary();
+ if (rawPublicKey.length !== ed25519KeySizeInBytes) {
+ throw new Error(`Unexpected Ed25519 public key length: ${rawPublicKey.length}`);
+ }
+
+ try {
+ this.publicKey = await crypto.subtle.importKey(
+ 'raw',
+ rawPublicKey,
+ 'Ed25519',
+ true,
+ ['verify'],
+ );
+ } catch (e) {
+ throw new Error('Failed to import Ed25519 public key: ' + e);
+ }
+ }
+
+ public async getPublicKeyBytes(algorithmName?: string): Promise {
+ if (!this.publicKey) {
+ return null;
+ }
+
+ let rawPublicKey: Buffer;
+ try {
+ rawPublicKey = Buffer.from(await crypto.subtle.exportKey('raw', this.publicKey));
+ } catch (e) {
+ throw new Error('Failed to export Ed25519 public key: ' + e);
+ }
+
+ // Write public key in SSH format.
+ algorithmName = algorithmName || WebEd25519.keyAlgorithmName;
+ const keyBuffer = Buffer.alloc(algorithmName.length + rawPublicKey.length + 8);
+ const keyWriter = new SshDataWriter(keyBuffer);
+ keyWriter.writeString(algorithmName, 'ascii');
+ keyWriter.writeBinary(rawPublicKey);
+
+ return keyWriter.toBuffer();
+ }
+
+ public async importParameters(parameters: EdDSAParameters): Promise {
+ if (!parameters.publicKey) throw new TypeError('Public key bytes are required.');
+ if (parameters.publicKey.length !== ed25519KeySizeInBytes) {
+ throw new Error(`Unexpected Ed25519 public key length: ${parameters.publicKey.length}`);
+ }
+
+ try {
+ this.publicKey = await crypto.subtle.importKey(
+ 'raw',
+ parameters.publicKey,
+ 'Ed25519',
+ true,
+ ['verify'],
+ );
+
+ if (parameters.privateKey) {
+ // WebCrypto Ed25519 uses PKCS#8 for private key import.
+ const pkcs8 = WebEd25519KeyPair.wrapPrivateKeyPkcs8(parameters.privateKey);
+ this.privateKey = await crypto.subtle.importKey(
+ 'pkcs8',
+ pkcs8,
+ 'Ed25519',
+ true,
+ ['sign'],
+ );
+ } else {
+ this.privateKey = undefined;
+ }
+ } catch (e) {
+ throw new Error('Failed to import Ed25519 key pair: ' + e);
+ }
+ }
+
+ public async exportParameters(): Promise {
+ if (!this.publicKey) {
+ throw new Error('Key not present.');
+ }
+
+ let rawPublicKey: Buffer;
+ try {
+ rawPublicKey = Buffer.from(await crypto.subtle.exportKey('raw', this.publicKey));
+ } catch (e) {
+ throw new Error('Failed to export Ed25519 public key: ' + e);
+ }
+
+ const parameters: EdDSAParameters = {
+ curve: { name: 'Ed25519' },
+ publicKey: rawPublicKey,
+ };
+
+ if (this.privateKey) {
+ try {
+ const pkcs8 = Buffer.from(await crypto.subtle.exportKey('pkcs8', this.privateKey));
+ parameters.privateKey = WebEd25519KeyPair.unwrapPrivateKeyPkcs8(pkcs8);
+ } catch (e) {
+ throw new Error('Failed to export Ed25519 private key: ' + e);
+ }
+ }
+
+ return parameters;
+ }
+
+ /**
+ * Wraps a raw 32-byte Ed25519 private key in PKCS#8 DER format.
+ *
+ * The PKCS#8 structure for Ed25519 is:
+ * SEQUENCE {
+ * INTEGER 0 (version)
+ * SEQUENCE { OID 1.3.101.112 (Ed25519) }
+ * OCTET STRING { OCTET STRING { raw private key } }
+ * }
+ */
+ private static wrapPrivateKeyPkcs8(rawPrivateKey: Buffer): Buffer {
+ // Pre-computed PKCS#8 prefix for Ed25519 (first 16 bytes of the structure).
+ const prefix = Buffer.from([
+ 0x30, 0x2e, // SEQUENCE (46 bytes)
+ 0x02, 0x01, 0x00, // INTEGER 0
+ 0x30, 0x05, // SEQUENCE (5 bytes)
+ 0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112
+ 0x04, 0x22, // OCTET STRING (34 bytes)
+ 0x04, 0x20, // OCTET STRING (32 bytes)
+ ]);
+ return Buffer.concat([prefix, rawPrivateKey]);
+ }
+
+ /**
+ * Extracts the raw 32-byte Ed25519 private key from PKCS#8 DER format.
+ */
+ private static unwrapPrivateKeyPkcs8(pkcs8: Buffer): Buffer {
+ // The raw key is the last 32 bytes of the PKCS#8 structure.
+ return pkcs8.slice(pkcs8.length - ed25519KeySizeInBytes);
+ }
+
+ public dispose(): void {}
+}
+
+export class WebEd25519 extends PublicKeyAlgorithm {
+ public static readonly keyAlgorithmName = 'ssh-ed25519';
+
+ public constructor() {
+ super(
+ WebEd25519.keyAlgorithmName,
+ WebEd25519.keyAlgorithmName,
+ '', // Ed25519 has a built-in hash (SHA-512); no separate hash algorithm.
+ );
+ }
+
+ public createKeyPair(): KeyPair {
+ return new WebEd25519KeyPair();
+ }
+
+ public async generateKeyPair(): Promise {
+ const keyPair = new WebEd25519KeyPair();
+ await keyPair.generate();
+ return keyPair;
+ }
+
+ public createSigner(keyPair: KeyPair): Signer {
+ if (!(keyPair instanceof WebEd25519KeyPair)) {
+ throw new TypeError('Ed25519 key pair object expected.');
+ }
+
+ return new WebEd25519SignerVerifier(keyPair);
+ }
+
+ public createVerifier(keyPair: KeyPair): Verifier {
+ if (!(keyPair instanceof WebEd25519KeyPair)) {
+ throw new TypeError('Ed25519 key pair object expected.');
+ }
+
+ return new WebEd25519SignerVerifier(keyPair);
+ }
+
+ public static readonly KeyPair = WebEd25519KeyPair;
+}
+
+class WebEd25519SignerVerifier implements Signer, Verifier {
+ public constructor(private readonly keyPair: WebEd25519KeyPair) {}
+
+ public get digestLength(): number {
+ return ed25519SignatureSizeInBytes;
+ }
+
+ public async sign(data: Buffer): Promise {
+ if (!this.keyPair.privateKey) {
+ throw new Error('Private key not set.');
+ }
+
+ const signature = Buffer.from(
+ await crypto.subtle.sign('Ed25519', this.keyPair.privateKey, data),
+ );
+ return signature;
+ }
+
+ public async verify(data: Buffer, signature: Buffer): Promise {
+ if (!this.keyPair.publicKey) {
+ throw new Error('Public key not set.');
+ }
+
+ const result = await crypto.subtle.verify(
+ 'Ed25519',
+ this.keyPair.publicKey,
+ signature,
+ data,
+ );
+ return result;
+ }
+
+ public dispose(): void {}
+}
+
+// eslint-disable-next-line no-redeclare
+export namespace WebEd25519 {
+ // eslint-disable-next-line no-shadow, @typescript-eslint/no-shadow
+ export type KeyPair = WebEd25519KeyPair;
+}
diff --git a/src/ts/ssh/index.ts b/src/ts/ssh/index.ts
index 005d3ce..0db504b 100644
--- a/src/ts/ssh/index.ts
+++ b/src/ts/ssh/index.ts
@@ -83,6 +83,8 @@ export {
RsaParameters,
ECDsa,
ECParameters,
+ Ed25519,
+ EdDSAParameters,
Random,
} from './algorithms/sshAlgorithms';
diff --git a/test/ts/ssh-test/cryptoTests.ts b/test/ts/ssh-test/cryptoTests.ts
index b3acfe0..f81f318 100644
--- a/test/ts/ssh-test/cryptoTests.ts
+++ b/test/ts/ssh-test/cryptoTests.ts
@@ -49,6 +49,7 @@ export class CryptoTests {
@params({ pkAlg: 'ecdsa-sha2-nistp256' })
@params({ pkAlg: 'ecdsa-sha2-nistp384' })
@params({ pkAlg: 'ecdsa-sha2-nistp521' })
+ @params({ pkAlg: 'ssh-ed25519' })
@params.naming((p) => `signVerify(${p.pkAlg})`)
public async signVerify({ pkAlg, keySize }: { pkAlg: string; keySize?: number }) {
const alg = Object.values(SshAlgorithms.publicKey).find((a) => a?.name === pkAlg)!;