密码在保护您的水疗中心
#react #node #cryptography #rsa

Cool image about cryptography

https://blog.1password.com/what-is-public-key-cryptography/归功于酷图像。

tl; dr:

检查此repository的nextjs中的一个简单示例,说明了如何实现这一目标。不过,建议阅读文章,以了解为什么有用的上下文。不要忘记给存储库的星星ð。

免责声明

尽管在过去十年中担任软件工程师,但我不是密码师,也不是网络专家。我是从负责修复错误的开发人员的角度分享这一点的。我建议您对该主题进行自己的研究,并始终邀请道德黑客享受您的应用程序。 在安全方面始终依靠专家

介绍

最近,我正在努力工作了一年以上的申请(穿透性测试,雇用的道德黑客将尝试入侵您的申请并报告您的弱点,以便您可以修复它们。这是网络安全非常有用的策略)。这是该系统第一次通过这样的程序进行。

系统

该系统由使用ReactJS构建的前端水疗中心以及使用Node.js构建的后端API组成。作为一名具有大约10年经验的软件工程师,我设计的两者都可以抵抗通常的罪魁祸首。

我会专注于这些,但我建议您广泛研究您不熟悉的任何术语。我很有信心,但我当时很疯狂。

那个报告

所有这些安全措施都在最终报告中受到赞扬。但是,有一次攻击能够通过。中型攻击的一种特殊形式,使黑客可以升级其访问水平。

该应用程序本身在两端都使用SSL证书保护,因此数据在运输过程中非常安全。但是,黑客使用了一个名为Burp Suite的专业工具,使用浏览器上的证书在机器上设置了代理。该代理将网络请求往返该工具的请求,并使两端都认为它是合法的。这使他可以修改他想要的任何数据。

攻击

他可以有效地伪造API将其发送回浏览器的内容,或者伪造浏览器发送给API的内容。因此,这并不是一个……中间的男人。这不是第三方窃取或更改信息,但是在这之间仍然是一个新层,这使攻击者可以做应用程序可能无法期望他能够做的事情,这可能会破坏事情。

我以前从未见过这种攻击。我什至不认为这是可能的。实际上,我的错,正如黑客所说的那样,这是一个非常普遍的水疗中心向量,它必须依靠通过网络的信息来确定用户可以看到和做什么参见,例如)。

从那里,黑客所要做的就是找出响应中的什么,以使浏览器相信他是管理员(例如,将“ isadmin”属性从“ false”更改为“ true” )。现在他可以看到他应该看到的一些东西,例如受限的页面和按钮。但是,由于后端验证了要求管理数据或执行行政诉讼的人是否是管理员,因此他可以用这种权力来做很多事情……我们认为……直到他找到一个弱点。

这是一种使我们能够快速创建新测试用户的表格。这是一项不应该看到的正常用户的功能,并且应该在开发后删除它,因此我们从不费心保护它,并且由于请求的正文专门创建了“普通用户”,因此我们从未停止过考虑安全含义。它从未被删除,我们忘记了。

然后,黑客使用代理来修改请求的主体,并设法创建了具有真正的管理员功率的新用户。他与这个新用户一起登录,系统掌握在手中。

我知道,这是一堆愚蠢的错误,但是您所有的终点都受到了保护吗?您确定吗?因为我很确定。肯定还不够。立即进行双重检查。

辩论 - 损害控制

显然,我们要做的第一件事是删除他的管理帐户,并正确地门控他用来创建用户的端点,要求管理员访问并阻止其接受该参数,以使该新用户admin访问。事实证明,我们仍然需要该表格进行某些测试,并且还不想删除它。我们还对与开发生产率有关的其他终点进行了扫描,以确认它们都是在管理员访问后面的门控,并修复了这些终点。

辩论-SSR?

那只猫从袋子里出来。我们需要解决方案。我们仍然必须防止攻击者看到他们不应该看到的页面和按钮。考虑了将整个React应用程序移至NextJS实例,因此我们可以依靠SSR处理ACL。基本上,我们将检查用户应该能够在服务器端看到的组件,此信息不会通过网络发送,因此无法伪造。这可能是解决此问题的最佳方法,它将在不久的将来完成,但这将非常耗时(而且并不总是可行),我们需要快速解决方案。

辩论 - 解决方案甚至会是什么样?

因此,我们需要一种方法来验证API发送的消息未被篡改。显然,我们需要某种形式的密码学。有人建议HMAC,但是该信息不能简单地使用双方共享的秘密加密,因为由于黑客可以在浏览器上访问源代码,因此他可以轻松地找到秘密并使用它来加密任何篡改的响应,因此,诸如HMAC(几乎任何形式的对称加密)之类的东西都不在大门。我需要一种在一侧签署消息的方法,另一侧能够验证签名是否有效,而另一侧不能签署消息。

辩论 - 解决方案

然后我们意识到:这听起来很像是公私的钥匙对,就像我们在SSH中使用的键一样!我们将有一个私钥,该密钥保留在API的环境上,我们将用来签署响应,并在前端编译的公共密钥以验证签名。这称为asymmetric cryptography。答对了!我们需要实现诸如RSA密钥之类的东西来签名和验证消息。有多困难?原来很难。至少,如果您像我一样,都不知道如何开始。

实现 - 创建密钥

经过数小时的反复试验,使用几个不同的命令(例如使用ssh-keygen,然后将公共密钥导出到PEM格式),我设法找到了正确创建键的命令。我不是一个密码师,并且可以详细解释为什么我尝试过的其他命令在进口钥匙的过程中失败了,但是从我的研究中,我可以得出结论,有几个不同的级别键,以及用于SSH的键与工作命令所创建的键。

这些是有效的。
对于私钥:
openssl genrsa -out private-key-name.pem 3072
对于公钥:
openssl rsa -in private-key-name.pem -pubout -out public-key-name.pem

您可以更改第一个命令中的位数,它们代表算法中使用的质量数量的位数(这是一个巨大的数字),但请记住,您必须更改一些以后其他事情。
根据经验,more bits = more security but less speed

实施 - 后端

在后端实现此目标非常简单。 nodejs有一个名为crypto的核心库,可用于签署几行代码。

我写了一个简单的响应包装器来做到这一点。它期望一个看起来像这样的输入:
{ b: 1, c: 3, a: 2 }
它的输出看起来像这样:

{
  content: { b: 1, c: 3, a: 2 },
  signature: "aBc123dEf456"
}

,但我立即遇到了问题,我很快就会解决这些问题,并简要解释了我如何解决它们。

  • 当您将JavaScript对象串入JSON时,它们不总是保留其形状。内容保持不变,但有时属性以不同的顺序出现。这是JSON的预期行为,并在its definition中进行了记录,但是如果我们要将其用作要签署的消息,则必须是平等的,是信函的信。我发现这个功能可以作为第二个论点传递给JSON.stringify,以实现我们所需的目标。它按字母顺序订购属性,因此我们可以计算它们将始终按正确的顺序串制。这就是功能的样子。
export const deterministicReplacer = (_, v) => {
  return typeof v !== 'object' || v === null || Array.isArray(v) ? v : Object.fromEntries(Object.entries(v).sort(([ka], [kb]) => {
    return ka < kb ? -1 : ka > kb ? 1 : 0
  }))
}

const message = JSON.stringify({ b: 2, c: 1, a: 3 }, deterministicReplacer)
// Will always output a previsible {"a":3,"b":2,"c":1}
  • 只是为了避免处理报价和括号,在某些情况下有时会逃脱,这会引起头痛,从而导致不同的字符串,我决定将整个拼写的JSON编码为base64。最初有效。
Buffer.from(message, 'ascii').toString('base64')
  • 后来我有问题,因为我正在阅读输入字符串作为ASCII的编码,事实证明,如果该消息包含任何需要超过1个字节的字符(例如表情符号或子弹点),则该过程将产生不良的签名,即前端无法验证。该解决方案是使用UTF-8而不是ASCII,但这需要修改前端的处理方式。稍后再详细介绍。
Buffer.from(message, 'utf-8').toString('base64')

这是后端的最终工作代码的样子:

import crypto from 'crypto'
import { deterministicReplacer } from '@/utils/helpers'

export const signContent = (content) => {
  const privateKey = process.env.PRIVATE_KEY
  if (!privateKey) {
    throw new Error('The environmental variable PRIVATE_KEY must be set')
  }
  const signer = crypto.createSign('RSA-SHA256')

  const message = JSON.stringify(content, deterministicReplacer)
  const base64Msg = Buffer.from(message, 'utf-8').toString('base64')
  signer.update(base64Msg)

  const signature = signer.sign(privateKey, 'base64')

  return signature
}

export const respondSignedContent = (res, code = 200, content = {}) => {
  const signature = signContent(content)
  res.status(code).send({ content, signature })
}

实施 - 前端

计划很简单:

  1. 收到内容和签名的响应。
  2. 确定性地串起content(使用我们在后端中使用的相同的deterministicReplacer函数)。
  3. 与后端一样
  4. 导入公钥。
  5. 使用公共密钥对响应中的签名验证此消息。
  6. 如果验证失败,则拒绝响应。

我四处寻找像crypto这样的图书馆寻找前端,尝试了其中一些,但最终空手而归。事实证明,该库是用C ++编写的,并且可以在浏览器上运行,因此我决定使用本机Web Crypto API,seems to work well on modern browsers

步骤1-3的代码很长,并且使用了我在Internet周围发现的一些几乎不可读的功能,然后以某种方式进行了修改和组合,以将所需格式的数据归一化。要充分查看,我建议直接转到文件rsa.tshelpers.ts

对于步骤4-5,我研究了WCAPI文档,以找出导入公钥的功能,希望数据以ArrayBuffer的形式(或其他数据)(或其他,请检查docs以获取参考)。钥匙自然带有标头,页脚和基本64中编码的身体(这是钥匙的实际内容),该键被编码为ASCII,因此我们可以使用window.atob函数。我们需要剥离标题和页脚,然后将其解码以获取其二进制数据。

这就是代码中的外观。

function textToUi8Arr(text: string): Uint8Array {
  let bufView = new Uint8Array(text.length)
  for (let i = 0; i < text.length; i++) {
    bufView[i] = text.charCodeAt(i)
  }
  return bufView
}


function base64StringToArrayBuffer(b64str: string): ArrayBufferLike {
  const byteStr = window.atob(b64str)
  return textToUi8Arr(byteStr).buffer
}


function convertPemToArrayBuffer(pem: string): ArrayBufferLike {
  const lines = pem.split('\n')
  let encoded = ''
  for (let i = 0; i < lines.length; i++) {
    if (lines[i].trim().length > 0 &&
      lines[i].indexOf('-BEGIN RSA PUBLIC KEY-') < 0 &&
      lines[i].indexOf('-BEGIN RSA PRIVATE KEY-') < 0 &&
      lines[i].indexOf('-BEGIN PUBLIC KEY-') < 0 &&
      lines[i].indexOf('-BEGIN PRIVATE KEY-') < 0 &&
      lines[i].indexOf('-END RSA PUBLIC KEY-') < 0 &&
      lines[i].indexOf('-END RSA PRIVATE KEY-') < 0 &&
      lines[i].indexOf('-END PUBLIC KEY-') < 0 &&
      lines[i].indexOf('-END PRIVATE KEY-') < 0
    ) {
      encoded += lines[i].trim()
    }
  }
  return base64StringToArrayBuffer(encoded)
}

导入密钥的最终代码看起来像:

const PUBLIC_KEY = process.env.NEXT_PUBLIC_PUBLIC_KEY


const keyConfig = {
  name: "RSASSA-PKCS1-v1_5",
  hash: {
    name: "SHA-256"
  },
  modulusLength: 3072, //The same number of bits used to create the key
  extractable: false,
  publicExponent: new Uint8Array([0x01, 0x00, 0x01])
}


async function importPublicKey(): Promise<CryptoKey | null> {
  if (!PUBLIC_KEY) {
    return null
  }
  const arrBufPublicKey = convertPemToArrayBuffer(PUBLIC_KEY)
  const key = await crypto.subtle.importKey(
    "spki", //has to be spki for importing public keys
    arrBufPublicKey,
    keyConfig,
    false, //false because we aren't exporting the key, just using it
    ["verify"] //has to be "verify" because public keys can't "sign"
  ).catch((e) => {
    console.log(e)
    return null
  })
  return key
}

然后,我们可以使用它来验证响应的内容和签名:

async function verifyIfIsValid(
  pub: CryptoKey,
  sig: ArrayBufferLike,
  data: ArrayBufferLike
) {
  return crypto.subtle.verify(keyConfig, pub, sig, data).catch((e) => {
    console.log('error in verification', e)
    return false
  })
}

export const verifySignature = async (message: any, signature: string) => {
  const publicKey = await importPublicKey()

  if (!publicKey) {
    return false //or throw an error
  }

  const msgArrBuf = stringifyAndBufferifyData(message)
  const sigArrBuf = base64StringToArrayBuffer(signature)

  const isValid = await verifyIfIsValid(publicKey, sigArrBuf, msgArrBuf)

  return isValid
}

检查上面链接的文件rsa.tshelpers.ts以查看StringifyAndBufferifyData的实现。

最后,对于第6步,只需使用验证函数,然后扔错误或做其他事情拒绝响应。

const [user, setUser] = useState<User>()
const [isLoading, setIsLoading] = useState<boolean>(false)
const [isRejected, setIsRejected] = useState<boolean>(false)

useEffect(() => {
  (async function () {
    setIsLoading(true)
    const res = await fetch('/api/user')
    const data = await res.json()

    const signatureVerified = await verifySignature(data.content, data.signature)
    setIsLoading(false)
    if (!signatureVerified) {
      setIsRejected(true)
      return
    }
    setUser(data.content)
  })()
}, [])

这显然只是一个例子。在我们的实施中,我们将此验证步骤写入了处理应用程序中所有请求的基础请求,并抛出了一个错误,该错误显示警告说响应在验证失败时被拒绝。

那就是你的做法。 ð

关于性能的注释

我们认为这可能会严重影响API的性能,但是响应时间的差异是无法察觉的。对于我们的3072位密钥,我们在响应时间中测量的差异平均小于10ms(对于4096位键的平均值小于20ms)。但是,由于相同的消息将始终产生相同的签名,因此可以轻松实现一种缓存机制,以提高Hot端点的性能,如果这成为问题。在此配置中,签名将始终是一个512字节字符串,因此,预计每个响应的大小会增加那么多,但是由于网络压缩,实际的网络流量增加较低。在示例中,{"name":"John Doe"} JSON的响应最终得到了130个字节。我们认为这是可以接受的妥协。

结果

邀请了同一个道德黑客再次尝试再次攻击应用程序,这一次,他无法做到。签名的验证一旦试图改变某些东西,他就会失败。他四处乱逛几天,后来报告说他不能打破这一点。现在声明了该申请现在足够安全 ...

结论

这有效,但我不会撒谎:为此目的没有找到有关如何执行此操作的全面材料使我质疑这是否是一个很好的解决方案。我认为分享这一点主要是对明智的人分析和/或批评它比我自己分析和/或批评的一种方式,但更重要的是,是一种警告其他开发人员的攻击向量的方式。我还想帮助其他人为此实施一个可能的解决方案,因为我花了几天的反复试验和错误,直到我能够弄清楚如何使一切一起工作。我希望这能节省您的时间。

所有这些都被凝结成NextJs的简化方法,并在this repository中可用。

如果您觉得有用或有用,请在上面留下一颗星星。

请完全自由批评这一点。正如我所说,我不是密码师或网络专家,并且会欣赏任何反馈。