From 754d6b3211cc7c2cf348482223590735a4db5c90 Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Fri, 6 Mar 2026 03:51:29 +0000 Subject: [PATCH] Add Ed25519 (ssh-ed25519) public key algorithm support Implement Ed25519 as a new PublicKeyAlgorithm with both WebCrypto and Node.js crypto backends. Includes key generation, signing, verification, SSH public key format import, and PKCS#8 import/export. Co-authored-by: Ona --- README.md | 15 +- src/ts/ssh-keys/pkcs8KeyFormatter.ts | 85 ++++++ src/ts/ssh-keys/publicKeyFormatter.ts | 4 +- src/ts/ssh/algorithms/node/nodeEd25519.ts | 241 +++++++++++++++++ src/ts/ssh/algorithms/publicKeyAlgorithm.ts | 6 + src/ts/ssh/algorithms/sshAlgorithms.ts | 16 +- src/ts/ssh/algorithms/web/webEd25519.ts | 274 ++++++++++++++++++++ src/ts/ssh/index.ts | 2 + test/ts/ssh-test/cryptoTests.ts | 1 + 9 files changed, 636 insertions(+), 8 deletions(-) create mode 100644 src/ts/ssh/algorithms/node/nodeEd25519.ts create mode 100644 src/ts/ssh/algorithms/web/webEd25519.ts 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)!;