diff --git a/libraries/deno-sshsig-mod.ts b/libraries/deno-sshsig-mod.ts new file mode 100644 index 0000000..b113530 --- /dev/null +++ b/libraries/deno-sshsig-mod.ts @@ -0,0 +1,344 @@ +import {crypto} from "jsr:@std/crypto"; +import {decodeBase64} from "jsr:@std/encoding/base64"; +import {encodeBase64Url} from "jsr:@std/encoding/base64url"; +import {encodeHex} from "jsr:@std/encoding/hex"; + +// Reference: +// * https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig +// * https://docs.deno.com/examples/hex_base64_encoding/ +// * https://jsr.io/@std/encoding/1.0.6/base64url.ts +// * https://docs.deno.com/examples/hashing/ +// * https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey +// * https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/verify + +// #define MAGIC_PREAMBLE "SSHSIG" +// #define SIG_VERSION 0x01 +// +// byte[6] MAGIC_PREAMBLE +// uint32 SIG_VERSION +// string publickey // @see public key format +// string namespace +// string reserved // always empty +// string hash_algorithm +// string signature // @see signature format +// +// public key format +// string ecdsa-sha2-nistp256|384|521 +// string nistp256|384|521 +// string ec public key point +// +// signature format +// string ecdsa-sha2-nistp256|384|521 +// string signature of ssh sign data // @see ec signature format +// +// ec signature format +// string r +// string s +// +// ssh sign data +// byte[6] MAGIC_PREAMBLE +// string namespace +// string reserved // always empty +// string hash_algorithm +// string input digest hash with @hash_algorithm + +const ENCODING_UTF8 = "UTF-8"; + +class BinaryWriter { + buffers: Array; + + constructor() { + this.buffers = []; + } + + merge(): Uint8Array { + let totalLen = 0; + for (let i = 0; i < this.buffers.length; i++) { + totalLen += this.buffers[i].byteLength; + } + let merged = new Uint8Array(totalLen); + let offset = 0; + for (let i = 0; i < this.buffers.length; i++) { + merged.set(this.buffers[i], offset); + offset += this.buffers[i].byteLength; + } + return merged; + } + + writeString(byte: Uint8Array) { + this._writeUint32(byte.byteLength); + this._writeBytes(byte); + } + + writeStringFromString(str: string) { + const bytes = new TextEncoder().encode(str); + this.writeString(bytes); + } + + writeLengthFromString(str: string) { + const bytes = new TextEncoder().encode(str); + this._writeBytes(bytes); + } + + _writeUint32(num: number) { + let dataView = new DataView(new ArrayBuffer(4), 0); + dataView.setUint32(0, num); + this._writeBytes(new Uint8Array(dataView.buffer)); + } + + _writeBytes(bytes: Uint8Array) { + this.buffers.push(bytes); + } +} + +class BinaryReader { + buffer: Uint8Array; + pos: number; + + constructor(buffer: Uint8Array) { + this.buffer = buffer; + this.pos = 0; + } + + readStringToString(): string { + const buff = this.readString(); + return new TextDecoder(ENCODING_UTF8).decode(buff); + } + + readLengthToString(len: number): string { + const buff = this._readBytes(len); + return new TextDecoder(ENCODING_UTF8).decode(buff); + } + + readString(): Uint8Array { + const len = this.readUint32(); + return this._readBytes(len); + } + + readUint32(): number { + const num = this._readBytes(4); + const dataView = new DataView(num.buffer, 0); + return dataView.getUint32(0); + } + + _readBytes(len: number): Uint8Array { + if (this.buffer.byteLength < (this.pos + len)) { + throw `Bad length ${this.buffer.byteLength} < ${this.pos} + ${len}`; + } + const bytes = this.buffer.slice(this.pos, this.pos + len); + this.pos += len; + return bytes; + } +} + +class SshSignature { + publicKey: SshPublicKey; + namespace: string; + hashAlgorithm: string; + signature: SshSignatureValue; + + constructor(publicKey: SshPublicKey, namespace: string, hashAlgorithm: string, signature: SshSignatureValue) { + this.publicKey = publicKey; + this.namespace = namespace; + this.hashAlgorithm = hashAlgorithm; + this.signature = signature; + } + + static parse(buffer: Uint8Array): SshSignature { + let reader = new BinaryReader(buffer); + const sshSig = reader.readLengthToString(6); + if (sshSig !== "SSHSIG") { + throw `Bad SSH signature magic, expect: SSHSIG, actual: ${sshSig}`; + } + let sshVer = reader.readUint32(); + if (sshVer !== 1) { + throw `Bad SSH signature version, expect: 1, actual: ${sshVer}`; + } + const publicKeyBytes = reader.readString(); + const publicKey = SshPublicKey.parse(publicKeyBytes); + const namespace = reader.readStringToString(); + const reserved = reader.readStringToString(); + if (reserved !== "") { + throw `Bad SSH signature reserved value, expect empty, actual: ${reserved}`; + } + const hashAlgorithm = reader.readStringToString(); + const signatureBytes = reader.readString(); + const signature = SshSignatureValue.parse(signatureBytes); + return new SshSignature(publicKey, namespace, hashAlgorithm, signature); + } + + async calculateSignatureData(filename: string): Promise { + const digest = await this._digestFile(filename); + let writer = new BinaryWriter(); + writer.writeLengthFromString("SSHSIG"); + writer.writeStringFromString(this.namespace); + writer.writeStringFromString(""); + writer.writeStringFromString(this.hashAlgorithm); + writer.writeString(digest); + return writer.merge(); + } + + async _digestFile(filename: string): Promise { + return await digestFile(filename, this.hashAlgorithm); + } +} + +class SshSignatureValue { + signatureAlgorithm: string; + ecSignatureR: Uint8Array; + ecSignatureS: Uint8Array; + + constructor(signatureAlgorithm: string, ecSignatureR: Uint8Array, ecSignatureS: Uint8Array) { + this.signatureAlgorithm = signatureAlgorithm; + this.ecSignatureR = ecSignatureR; + this.ecSignatureS = ecSignatureS; + } + + static parse(buffer: Uint8Array): SshSignatureValue { + let reader = new BinaryReader(buffer); + const signatureAlgorithm = reader.readStringToString(); + const signatureEc = reader.readString(); + let signatureEcReader = new BinaryReader(signatureEc); + const r = signatureEcReader.readString(); + const s = signatureEcReader.readString(); + return new SshSignatureValue(signatureAlgorithm, r, s); + } +} + +class SshPublicKey { + signatureAlgorithm: string; + algorithm: string; + publicKeyPoint: Uint8Array; + + constructor(signatureAlgorithm: string, algorithm: string, publicKeyPoint: Uint8Array) { + this.signatureAlgorithm = signatureAlgorithm; + this.algorithm = algorithm; + this.publicKeyPoint = publicKeyPoint; + } + + static parse(buffer: Uint8Array): SshPublicKey { + let reader = new BinaryReader(buffer); + const signatureAlgorithm = reader.readStringToString(); + const curveAlgorithm = reader.readStringToString(); + const publicKeyPoint = reader.readString(); + if (signatureAlgorithm !== `ecdsa-sha2-${curveAlgorithm}`) { + throw `Not supported signature algorithm ${signatureAlgorithm} or curve algorithm ${curveAlgorithm}`; + } + return new SshPublicKey(signatureAlgorithm, curveAlgorithm, publicKeyPoint); + } + + async importJwk(): Promise { + let jwk = this.toJwk(); + return await crypto.subtle.importKey( + "jwk", + jwk, + { + name: "ECDSA", + namedCurve: (this.algorithm === "nistp256") ? "P-256" : "P-384", + }, + true, + ["verify"], + ); + } + + toJwk(): any { + if (this.publicKeyPoint[0] !== 0x04) { + throw `Invalid EC public key point: ${encodeHex(this.publicKeyPoint)}`; + } + let coordinateLength; + if (this.algorithm === "nistp256") { + coordinateLength = 256 / 8; + } else if (this.algorithm === "nistp384") { + coordinateLength = 384 / 8; + } else { + throw `Not supported alrogithm: ${this.algorithm}`; + } + const x = this.publicKeyPoint.slice(1, coordinateLength + 1); + const y = this.publicKeyPoint.slice(coordinateLength + 1, coordinateLength + coordinateLength + 1); + return { + crv: (this.algorithm === "nistp256") ? "P-256" : "P-384", + ext: true, + key_ops: ["verify"], + kty: "EC", + x: encodeBase64Url(x), + y: encodeBase64Url(y), + }; + } +} + +function parsePem(pem: string): Uint8Array { + const lines = pem.split("\n"); + let isInPem = false; + let innerPem = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (isInPem) { + if (line.indexOf("-----END ") === 0) { + isInPem = false; + break; + } else { + innerPem.push(line); + } + } else if (line.indexOf("-----BEGIN ") === 0) { + isInPem = true; + } + } + if (innerPem.length === 0) { + return null; + } + return decodeBase64(innerPem.join("")); +} + +async function digestFile(filename: string, algorithm: string): Promise { + let hashAlgorithm; + switch (algorithm.toLowerCase()) { + case "sha256": + case "sha-256": + hashAlgorithm = "SHA-256"; + break; + case "sha384": + case "sha-384": + hashAlgorithm = "SHA-384"; + break; + case "sha512": + case "sha-512": + hashAlgorithm = "SHA-512"; + break; + default: + throw `Unknown algorithm: ${algorithm}`; + } + const file = await Deno.open(filename, {read: true}); + const readableStream = file.readable; + const fileHashBuffer = await crypto.subtle.digest(hashAlgorithm, readableStream); + return new Uint8Array(fileHashBuffer); +} + +const TEST_SIG = `-----BEGIN SSH SIGNATURE----- +U1NIU0lHAAAAAQAAAGgAAAATZWNkc2Etc2hhMi1uaXN0cDI1NgAAAAhuaXN0cDI1 +NgAAAEEE3T7r2QbJzwCwjsKfftYYBNHMHRNS2SV7YoGR4I/DcXxPrjKYzVxIKc7I +vzqUbn22C3hX4Sh/aguuaz8jQvAH0AAAAARmaWxlAAAAAAAAAAZzaGE1MTIAAABk +AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAABJAAAAIQDD7MAiklQEsY3Dq3Zy25Zz +9y4mNmPAWlbQPAAg2zyc1wAAACAdQZcn+tnINX8PRt3mHTxpE2+8I/ioTPWHhne1 +0oJ+4w== +-----END SSH SIGNATURE-----`; + +const sshSignature = SshSignature.parse(parsePem(TEST_SIG)); +console.log(sshSignature); + +const file = "/Users/hatterjiang/temp/sigstore-tests/hello.txt"; +const sig = await sshSignature.calculateSignatureData(file); +console.log(encodeHex(sig)); + +const publicKey = await sshSignature.publicKey.importJwk(); +console.log(publicKey); + +// crypto.subtle.verify( +// { +// name: "ECDSA", +// hash: { name: "SHA-256" }, +// }, +// publicKey, +// signature, +// encoded, +// ); +