diff --git a/modules/ecash-lib/src/index.ts b/modules/ecash-lib/src/index.ts --- a/modules/ecash-lib/src/index.ts +++ b/modules/ecash-lib/src/index.ts @@ -27,3 +27,5 @@ export * from './token/common.js'; export * from './token/empp.js'; export * from './token/slp.js'; + +export * as payment from './payment'; diff --git a/modules/ecash-lib/src/payment/asn1.test.ts b/modules/ecash-lib/src/payment/asn1.test.ts new file mode 100644 --- /dev/null +++ b/modules/ecash-lib/src/payment/asn1.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2025 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import { expect } from 'chai'; +import * as tls from 'node:tls'; + +import { asn1, x509 } from '.'; +import { Ecc } from '../ecc.js'; +import { toHex } from '../io/hex.js'; +import { sha256 } from '../hash.js'; +import '../initNodeJs.js'; + +/* +key.pem: +openssl ecparam -genkey -out key.pem -name secp256k1 + +-----BEGIN EC PARAMETERS----- +BgUrgQQACg== +-----END EC PARAMETERS----- +-----BEGIN EC PRIVATE KEY----- +MHQCAQEEIFzOgjk70LwSfTAWNAzwm2tZedMM30pwyMsaK8KkJAEHoAcGBSuBBAAK +oUQDQgAE23jFaVYo7rulgXbZ5WaADFaKCzn8Lk6ZubOqabsXrqfM3JaKRrrPwyrk +aXzvoFu5D28kF7OXDsomtTWH9h5Slg== +-----END EC PRIVATE KEY----- + +cert.pem: +openssl req -x509 -new \ + -key key.pem \ + -out cert.pem \ + -sha256 \ + -days 36500 \ + -nodes \ + -subj "/C=CA/ST=British Columbia/L=Vancouver/O=eCash Palace/OU=Software/CN=example.e.cash/emailAddress=example@e.cash" +*/ +const CERT_PEM = ` +-----BEGIN CERTIFICATE----- +MIICkTCCAjigAwIBAgIUTWHooPqrdMM/kO1CNMYN7m3P6CswCgYIKoZIzj0EAwIw +gZ4xCzAJBgNVBAYTAkNBMRkwFwYDVQQIDBBCcml0aXNoIENvbHVtYmlhMRIwEAYD +VQQHDAlWYW5jb3V2ZXIxFTATBgNVBAoMDGVDYXNoIFBhbGFjZTERMA8GA1UECwwI +U29mdHdhcmUxFzAVBgNVBAMMDmV4YW1wbGUuZS5jYXNoMR0wGwYJKoZIhvcNAQkB +Fg5leGFtcGxlQGUuY2FzaDAgFw0yNTAzMDIyMDMyNDNaGA8yMTI1MDIwNjIwMzI0 +M1owgZ4xCzAJBgNVBAYTAkNBMRkwFwYDVQQIDBBCcml0aXNoIENvbHVtYmlhMRIw +EAYDVQQHDAlWYW5jb3V2ZXIxFTATBgNVBAoMDGVDYXNoIFBhbGFjZTERMA8GA1UE +CwwIU29mdHdhcmUxFzAVBgNVBAMMDmV4YW1wbGUuZS5jYXNoMR0wGwYJKoZIhvcN +AQkBFg5leGFtcGxlQGUuY2FzaDBWMBAGByqGSM49AgEGBSuBBAAKA0IABNt4xWlW +KO67pYF22eVmgAxWigs5/C5Ombmzqmm7F66nzNyWika6z8Mq5Gl876BbuQ9vJBez +lw7KJrU1h/YeUpajUzBRMB0GA1UdDgQWBBQKzjEN/xneEG0oc/OgW/w7/imk4TAf +BgNVHSMEGDAWgBQKzjEN/xneEG0oc/OgW/w7/imk4TAPBgNVHRMBAf8EBTADAQH/ +MAoGCCqGSM49BAMCA0cAMEQCIDOPA89LS1r0/3nm8i4hT/VhHHEYPh3Mf42COhRP +bpoDAiA2Mf29zeOXOxZ7B021KAJUpsSFtMEvBnllMQjQqsoaog== +-----END CERTIFICATE----- +`; + +describe('ASN1', () => { + const ecc = new Ecc(); + it('asn1.parseCertPem self-signed secp256k1', () => { + const cert = asn1.parseCertPem(CERT_PEM); + + expect(cert.sigAlg).to.deep.equal({ + alg: x509.OID_ECDSA_WITH_SHA256, + params: undefined, + }); + const subject = [ + { + oid: x509.OID_COUNTRY_NAME, + value: 'CA', + }, + { + oid: x509.OID_STATE_OR_PROVINCE_NAME, + value: 'British Columbia', + }, + { + oid: x509.OID_LOCALITY_NAME, + value: 'Vancouver', + }, + { + oid: x509.OID_ORGANIZATION_NAME, + value: 'eCash Palace', + }, + { + oid: x509.OID_ORGANIZATIONAL_UNIT_NAME, + value: 'Software', + }, + { + oid: x509.OID_COMMON_NAME, + value: 'example.e.cash', + }, + { + oid: x509.OID_EMAIL_ADDRESS, + value: 'example@e.cash', + }, + ]; + + expect(cert.tbs.version).to.equal(2); + expect(cert.tbs.sigAlg).to.deep.equal({ + alg: x509.OID_ECDSA_WITH_SHA256, + params: undefined, + }); + expect(toHex(cert.tbs.serial)).to.equal( + '4d61e8a0faab74c33f90ed4234c60dee6dcfe82b', + ); + expect(cert.tbs.issuer).to.deep.equal(subject); + expect(cert.tbs.validity).to.deep.equal({ + notBefore: 1740947563, + notAfter: 4894547563, + }); + expect(cert.tbs.subject).to.deep.equal(subject); + expect(cert.tbs.pubkey.alg).to.deep.equal({ + alg: x509.OID_EC_PUBLIC_KEY, + params: x509.OID_ANSIP256K1, + }); + expect(toHex(cert.tbs.pubkey.data)).to.equal( + '04db78c5695628eebba58176d9e566800c568a0b39fc2e4e99b9b3aa69bb17aea7ccdc968a46bacfc32ae4697cefa05bb90f6f2417b3970eca26b53587f61e5296', + ); + + // Self-signed certificate is valid + ecc.ecdsaVerify( + cert.sig, + sha256(cert.tbs.raw), + ecc.compressPk(cert.tbs.pubkey.data), + ); + }); + + it('asn1.parseCertPem built-in root certs', () => { + // Try parsing all built in root certificates + for (const certPem of tls.rootCertificates) { + // Throws an error if one cert failed + const cert = asn1.parseCertPem(certPem); + // Cert has at least one entry in subject + expect(cert.tbs.subject.length).to.be.greaterThan(0); + } + }); +}); diff --git a/modules/ecash-lib/src/payment/asn1.ts b/modules/ecash-lib/src/payment/asn1.ts new file mode 100644 --- /dev/null +++ b/modules/ecash-lib/src/payment/asn1.ts @@ -0,0 +1,443 @@ +// Copyright (c) 2025 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import { bytesToStr } from '../io/str.js'; +import { Bytes } from '../io/bytes.js'; + +interface Tag { + type: number; + isPrimitive: boolean; + size: number; +} + +/** Identifier of a signature algorithm */ +export interface AlgIdent { + /** OID of the algorithm */ + alg: string; + /** OID of the params */ + params: string | undefined; +} + +/** Entry of an issuer/subject */ +export interface Entry { + /** OID of the entry */ + oid: string; + /** Value of the entry */ + value: string; +} + +/** Time range of the validity of the certificate */ +export interface Validity { + /** Certificate is not valid before this UNIX timestamp */ + notBefore: number; + /** Certificate is not valid after this UNIX timestamp */ + notAfter: number; +} + +/** Public key of a certificate */ +export interface PubKey { + /** Signature algorithm of the public key */ + alg: AlgIdent; + /** Bytes of the public key */ + data: Uint8Array; +} + +/** To-be-signed data of the certificate */ +export interface TBS { + /** Version of the certificate data */ + version: number | undefined; + /** Serial number of the certificate */ + serial: Uint8Array; + /** Signature algorithm used by the certificate */ + sigAlg: AlgIdent; + /** Fields of the issuer of the certificate */ + issuer: Entry[]; + /** Time range the certificate is valid */ + validity: Validity; + /** Fields of the subject of the certificate */ + subject: Entry[]; + /** Public key of the certificate */ + pubkey: PubKey; + /** Raw bytes of the data to be signed */ + raw: Uint8Array; +} + +export interface Cert { + /** Data to be signed by the signature */ + tbs: TBS; + /** Algorithm used for the signature */ + sigAlg: AlgIdent; + /** Signature signing `tbs` */ + sig: Uint8Array; + /** Raw bytes of the certificate */ + raw: Uint8Array; +} + +const TAG_VERSION = 0x00; +const TAG_INT = 0x02; +const TAG_BITSTR = 0x03; +const TAG_OCTSTR = 0x04; +const TAG_NULL = 0x05; +const TAG_OID = 0x06; +const TAG_UTF8STR = 0x0c; +const TAG_SEQ = 0x10; +const TAG_SET = 0x11; +const TAG_NUMSTR = 0x12; +const TAG_PRINSTR = 0x13; +const TAG_T61STR = 0x14; +const TAG_VIDEOSTR = 0x15; +const TAG_IA5STR = 0x16; +const TAG_UTCTIME = 0x17; +const TAG_GENTIME = 0x18; +const TAG_GRAPHSTR = 0x19; +const TAG_ISO646STR = 0x1a; +const TAG_GENSTR = 0x1b; +const TAG_UNISTR = 0x1c; +const TAG_CHARSTR = 0x1d; +const TAG_BMPSTR = 0x1e; + +const REGEX_PEM_CERT = + /-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----/s; +const REGEX_DIGITS = /\d+/; + +function readSize(bytes: Bytes, isPrimitive: boolean): number { + let size = bytes.readU8(); + + // Indefinite form + if (!isPrimitive && size === 0x80) { + throw new Error('Indefinite size.'); + } + + // Definite form + if ((size & 0x80) === 0) { + // Short form + return size; + } + + // Long form + const numBytes = size & 0x7f; + + if (numBytes > 3) { + throw new Error('Length octet is too long.'); + } + + size = 0; + for (let i = 0; i < numBytes; i++) { + size <<= 8; + size |= bytes.readU8(); + } + + return size; +} + +function readTag(bytes: Bytes): Tag { + let type = bytes.readU8(); + const isPrimitive = (type & 0x20) === 0; + + if ((type & 0x1f) === 0x1f) { + let oct = type; + type = 0; + while ((oct & 0x80) === 0x80) { + oct = bytes.readU8(); + type <<= 7; + type |= oct & 0x7f; + } + } else { + type &= 0x1f; + } + + return { + type, + isPrimitive, + size: readSize(bytes, isPrimitive), + }; +} + +function readSeq(bytes: Bytes): Uint8Array { + const tag = readTag(bytes); + if (tag.type !== TAG_SEQ) { + throw new Error( + `Expected sequence type ${TAG_SEQ}, but got ${tag.type}`, + ); + } + return bytes.readBytes(tag.size); +} + +function alignBitstr(data: Uint8Array): Uint8Array { + const padding = data[0]; + const bits = (data.length - 1) * 8 - padding; + const buf = data.slice(1); + const shift = 8 - (bits % 8); + + if (shift === 8 || buf.length === 0) { + return buf; + } + + const out = Buffer.allocUnsafe(buf.length); + out[0] = buf[0] >>> shift; + + for (let i = 1; i < buf.length; i++) { + out[i] = buf[i - 1] << (8 - shift); + out[i] |= buf[i] >>> shift; + } + + return out; +} + +function readBitstr(bytes: Bytes): Uint8Array { + const tag = readTag(bytes); + if (tag.type !== TAG_BITSTR) { + throw new Error( + `Expected sequence type ${TAG_BITSTR}, but got ${tag.type}`, + ); + } + return alignBitstr(bytes.readBytes(tag.size)); +} + +function readString(bytes: Bytes): Uint8Array { + const tag = readTag(bytes); + switch (tag.type) { + case TAG_BITSTR: { + return alignBitstr(bytes.readBytes(tag.size)); + } + case TAG_OCTSTR: + case TAG_NUMSTR: + case TAG_PRINSTR: + case TAG_T61STR: + case TAG_VIDEOSTR: + case TAG_IA5STR: + case TAG_GRAPHSTR: + case TAG_UTF8STR: + case TAG_ISO646STR: + case TAG_GENSTR: + case TAG_UNISTR: + case TAG_CHARSTR: + case TAG_BMPSTR: { + return bytes.readBytes(tag.size); + } + default: { + throw new Error(`Expected string tag, got ${tag.type}`); + } + } +} + +function readInt(bytes: Bytes): Uint8Array { + const tag = readTag(bytes); + if (tag.type !== TAG_INT) { + throw new Error( + `Expected integer type ${TAG_INT}, but got ${tag.type}`, + ); + } + return bytes.readBytes(tag.size); +} + +function bytesToBE(bytes: Uint8Array): number { + let num = 0; + for (const b of bytes) { + num <<= 8; + num |= b; + } + return num; +} + +function readVersion(bytes: Bytes): number | undefined { + const startIdx = bytes.idx; + const tag = readTag(bytes); + if (tag.type != TAG_VERSION) { + bytes.idx = startIdx; + return undefined; + } + return bytesToBE(readInt(bytes)); +} + +function readAlgIdent(bytes: Bytes): AlgIdent { + let params: string | undefined = undefined; + bytes = new Bytes(readSeq(bytes)); + const alg = readOID(bytes); + if (alg === undefined) { + throw new Error('Algorithm cannot be NULL'); + } + + if (bytes.idx < bytes.data.length) { + params = readOID(bytes); + } + + return { + alg: alg, + params: params, + }; +} + +function readOID(bytes: Bytes): string | undefined { + const tag = readTag(bytes); + if (tag.type === TAG_NULL) { + return undefined; + } + + if (tag.type !== TAG_OID) { + throw new Error(`Expected OID tag ${TAG_OID}, but got ${tag.type}`); + } + const data = bytes.readBytes(tag.size); + const ids = []; + let ident = 0; + let subident = 0; + for (const byte of data) { + subident = byte; + ident <<= 7; + ident |= subident & 0x7f; + if ((subident & 0x80) === 0) { + ids.push(ident); + ident = 0; + } + } + + if (subident & 0x80) { + ids.push(ident); + } + + const first = (ids[0] / 40) | 0; + const second = ids[0] % 40; + const result = [first, second].concat(ids.slice(1)); + + return result.join('.'); +} + +function readEntries(bytes: Bytes): Entry[] { + const values = []; + bytes = new Bytes(readSeq(bytes)); + + while (bytes.idx < bytes.data.length) { + const tagSet = readTag(bytes); + if (tagSet.type !== TAG_SET) { + throw new Error( + `Expected set tag ${TAG_SET}, but got ${tagSet.type}`, + ); + } + const tagSeq = readTag(bytes); + if (tagSeq.type !== TAG_SEQ) { + throw new Error( + `Expected seq tag ${TAG_SEQ}, but got ${tagSeq.type}`, + ); + } + const oid = readOID(bytes); + if (oid === undefined) { + throw new Error('OID for issuer or subject cannot be NULL'); + } + values.push({ + oid: oid, + value: bytesToStr(readString(bytes)), + }); + } + + return values; +} + +function readTime(bytes: Bytes): number { + const tag = readTag(bytes); + const decoder = new TextDecoder('ascii', { fatal: true }); + const str = decoder.decode(bytes.readBytes(tag.size)); + let year: number; + let mon: number; + let day: number; + let hour: number; + let min: number; + let sec: number; + + let pos = 0; + const readDigits = (numDigits: number) => { + const digits = str.slice(pos, pos + numDigits); + if (!REGEX_DIGITS.test(digits)) { + throw new Error(`Expected ${numDigits} decimal digits`); + } + pos += numDigits; + return Number(digits) | 0; + }; + + switch (tag.type) { + case TAG_UTCTIME: { + year = readDigits(2); + mon = readDigits(2); + day = readDigits(2); + hour = readDigits(2); + min = readDigits(2); + sec = readDigits(2); + if (year < 70) { + year = 2000 + year; + } else { + year = 1900 + year; + } + break; + } + case TAG_GENTIME: { + year = readDigits(4); + mon = readDigits(2); + day = readDigits(2); + hour = readDigits(2); + min = readDigits(2); + sec = readDigits(2); + break; + } + default: { + throw new Error(`Unexpected tag: ${tag.type}.`); + } + } + + return Date.UTC(year, mon - 1, day, hour, min, sec, 0) / 1000; +} + +function readValidity(bytes: Bytes): Validity { + bytes = new Bytes(readSeq(bytes)); + return { + notBefore: readTime(bytes), + notAfter: readTime(bytes), + }; +} + +function readPubkey(bytes: Bytes): PubKey { + bytes = new Bytes(readSeq(bytes)); + return { + alg: readAlgIdent(bytes), + data: readBitstr(bytes), + }; +} + +function readToBeSigned(bytes: Bytes): TBS { + const startIdx = bytes.idx; + const tbsBytes = new Bytes(readSeq(bytes)); + const endIdx = bytes.idx; + return { + version: readVersion(tbsBytes), + serial: readInt(tbsBytes), + sigAlg: readAlgIdent(tbsBytes), + issuer: readEntries(tbsBytes), + validity: readValidity(tbsBytes), + subject: readEntries(tbsBytes), + pubkey: readPubkey(tbsBytes), + raw: bytes.data.slice(startIdx, endIdx), + }; +} + +/** Parse a ASN1 certificate from the given bytes */ +export function parseCertRaw(rawCert: Uint8Array): Cert { + const bytes = new Bytes(rawCert); + const certBytes = new Bytes(readSeq(bytes)); + return { + tbs: readToBeSigned(certBytes), + sigAlg: readAlgIdent(certBytes), + sig: readBitstr(certBytes), + raw: rawCert, + }; +} + +export function parseCertPem(pem: string): Cert { + const match = REGEX_PEM_CERT.exec(pem); + if (match === null) { + throw new Error( + 'No PEM encoded certificate found. It should start with ' + + '"-----BEGIN CERTIFICATE-----"', + ); + } + const certRaw = Uint8Array.from(atob(match[1]), c => c.charCodeAt(0)); + return parseCertRaw(certRaw); +} diff --git a/modules/ecash-lib/src/payment/index.ts b/modules/ecash-lib/src/payment/index.ts new file mode 100644 --- /dev/null +++ b/modules/ecash-lib/src/payment/index.ts @@ -0,0 +1,6 @@ +// Copyright (c) 2025 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +export * as asn1 from './asn1.js'; +export * as x509 from './x509.js'; diff --git a/modules/ecash-lib/src/payment/x509.ts b/modules/ecash-lib/src/payment/x509.ts new file mode 100644 --- /dev/null +++ b/modules/ecash-lib/src/payment/x509.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2025 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +export const OID_ECDSA_WITH_SHA256 = '1.2.840.10045.4.3.2'; +export const OID_EC_PUBLIC_KEY = '1.2.840.10045.2.1'; +export const OID_ANSIP256K1 = '1.3.132.0.10'; + +export const OID_COUNTRY_NAME = '2.5.4.6'; +export const OID_STATE_OR_PROVINCE_NAME = '2.5.4.8'; +export const OID_LOCALITY_NAME = '2.5.4.7'; +export const OID_ORGANIZATION_NAME = '2.5.4.10'; +export const OID_ORGANIZATIONAL_UNIT_NAME = '2.5.4.11'; +export const OID_COMMON_NAME = '2.5.4.3'; +export const OID_EMAIL_ADDRESS = '1.2.840.113549.1.9.1';