與 JOSE 戰斗的日子 - 寫給 iOS 開發者的密碼學入門手冊 (實踐)

image

概述

這是關于 JOSE 和密碼學的三篇系列文章中的最后一篇,你可以在下面的鏈接中找到其他部分:

  1. 基礎 - 什么是 JWT 以及 JOSE
  2. 理論 - JOSE 中的簽名和驗證流程
  3. 實踐 - 如何使用 Security.framework 處理 JOSE 中的驗證 (本文)

推薦閱讀:iOS開發——2019 最新 BAT面試題合集(持續更新中)

這一篇中,我們會在 JOSE 基礎篇和理論篇的知識架構上,使用 iOS (或者說 Cocoa) 的相關框架來完成對 JWT 的解析,并利用 JWK 對它的簽名進行驗證。在最后,我會給出一些我自己在實現和學習這些內容時的思考,并把一些相關工具和標準列舉一下。

作為一個開發者,有一個學習的氛圍跟一個交流圈子特別重要,這是一個我的iOS交流群:638302184,不管你是小白還是大牛歡迎入駐 ,分享BAT,阿里面試題、面試經驗,討論技術, 大家一起交流學習成長!

群內提供數據結構與算法、底層進階、swift、逆向、整合面試題等免費資料
附上一份收集的各大廠面試題(附答案) ! 群文件直接獲取
各大廠面試題

解碼 JWT

JWT,或者更精確一點,JWS 中的 Header 和 Payload 都是 Base64Url 編碼的。為了獲取原文內容,先需要對 Header 和 Payload 解碼。

Base64Url

Base64 相信大家都已經很熟悉了,隨著網絡普及,這套編碼有一個很大的“缺點”,就是使用了 +/=。這些字符在 URL 里是很不友好的,在作為傳輸時需要額外做 escaping。Base64Url 就是針對這個問題的改進,具體來說就是:

  1. + 替換為 -
  2. / 替換為 _
  3. 將末尾的 = 干掉。

相關代碼的話非常簡單,為 DataString 分別添加 extension 來相互轉換就好:

extension Data {
    // Encode `self` with URL escaping considered.
    var base64URLEncoded: String {
        let base64Encoded = base64EncodedString()
        return base64Encoded
            .replacingOccurrences(of: "+", with: "-")
            .replacingOccurrences(of: "/", with: "_")
            .replacingOccurrences(of: "=", with: "")
    }
}

extension String {
    // Returns the data of `self` (which is a base64 string), with URL related characters decoded.
    var base64URLDecoded: Data? {
        let paddingLength = 4 - count % 4
        // Filling = for %4 padding.
        let padding = (paddingLength < 4) ? String(repeating: "=", count: paddingLength) : ""
        let base64EncodedString = self
            .replacingOccurrences(of: "-", with: "+")
            .replacingOccurrences(of: "_", with: "/")
            + padding
        return Data(base64Encoded: base64EncodedString)
    }
}

結合使用 JSONDecoder 和 Base64Url 來處理 JWT

因為 JWT 的 Header 和 Payload 部分實際上是有效的 JSON,為了簡單,我們可以利用 Swift 的 Codable 來解析 JWT。為了簡化處理,可以封裝一個針對以 Base64Url 表示的 JSON 的 decoder:

class  Base64URLJSONDecoder: JSONDecoder {
    override func decode<T>(_ type: T.Type, from data: Data) throws -> T where T : Decodable {
        guard let string = String(data: data, encoding: .ascii) else {
            // 錯誤處理
        }

        return try decode(type, from: string)
    }

    func decode<T>(_ type: T.Type, from string: String) throws -> T where T : Decodable {
        guard let decodedData = string.base64URLDecoded else {
            // 錯誤處理
        }
        return try super.decode(type, from: decodedData)
    }
}

Base64URLJSONDecoder 將 Base64Url 的轉換封裝到解碼過程中,這樣一來,我們只需要獲取 JWT,將它用 . 分割開,然后使用 Base64URLJSONDecoder 就能把 Header 和 Payload 輕易轉換了,比如:

struct Header: Codable {
    let algorithm: String
    let tokenType: String?
    let keyID: String?

    enum CodingKeys: String, CodingKey {
        case algorithm = "alg"
        case tokenType = "typ"
        case keyID = "kid"
    }
}

let jwtRaw = "eyJhbGciOiJSUzI1NiI..." // JWT 字符串,后面部分省略了
let rawComponents = text.components(separatedBy: ".")
let decoder = Base64JSONDecoder()
let header = try decoder.decode(Header.self, from: rawComponents[0])

guard let keyID = header.keyID else { /* 驗證失敗 */ }

在 Header 中,我們應該可以找到指定了驗證簽名所需要使用的公鑰的 keyID。如果沒有的話,驗證失敗,登錄過程終止。

對于簽名,我們將解碼后的原始的 Data 保存下來,稍后使用。同樣地,我們最好也保存一下 {Header}.{Payload} 的部分,它在驗證中也會被使用到:

let signature = rawComponents[2].base64URLDecoded!
let plainText = "\(rawComponents[0]).\(rawComponents[1])"

這里的代碼基本都沒有考慮錯誤處理,大部分是直接讓程序崩潰。實際的產品中驗證簽名過程中的錯誤應該被恰當處理,而不是粗暴掛掉。

在 Security.framework 中處理簽名

我們已經準備好簽名的數據和原文了,萬事俱備,只欠密鑰。

處理密鑰

通過 keyID,在預先設定的 JWT Host 中我們應該可以找到以 JWK 形式表示的密鑰。我們計劃使用 Security.framework 來處理密鑰和簽名驗證,首先要做的就是遵守框架和 JWA 的規范,通過 JWK 的密鑰生成 Security 框架喜歡的 SecKey 值。

在其他大部分情況下,我們可能會從一個證書 (certificate,不管是從網絡下載的 PEM 還是存儲在本地的證書文件) 里獲取公鑰。像是處理 HTTPS challenge 或者 SSL Pinning 的時候,大部分情況下我們拿到的是完整的證書數據,通過 SecCertificateCreateWithData 使用 DER 編碼的數據創建證書并獲取公鑰:

guard let cert = SecCertificateCreateWithData(nil, data as CFData) else {
    // 錯誤處理
    return
}

let policy = SecPolicyCreateBasicX509()
var trust: SecTrust? = nil
SecTrustCreateWithCertificates(cert, policy, &trust)
guard let t = trust, let key: SecKey = SecTrustCopyPublicKey(t) else {
    // 錯誤處理
    return
}
print(key)

但是,在 JWK 的場合,我們是沒有 X.509 證書的。JWK 直接將密鑰類型和參數編碼在 JSON 中,我們當然可以按照 DER 編碼規則將這些信息編碼回一個符合 X.509 要求的證書,然后使用上面的方法再從中獲取證書。不過這顯然是畫蛇添足,我們完全可以直接通過這些參數,使用特定格式的數據來直接生成 SecKey

有可能有同學會迷惑于“公鑰”和“證書”這兩個概念。一個證書,除了包含有公鑰以外,還包含有像是證書發行者,證書目的,以及其他一些元數據的信息。因此,我們可以從一個證書中,提取它所存儲的公鑰。

另外,證書本身一般會由另外一個私鑰進行簽名,并由頒發機構或者受信任的機構進行驗證保證其真實性。

使用 SecKeyCreateWithData 就可以直接通過公鑰參數來生成了:

func SecKeyCreateWithData(_ keyData: CFData, 
                          _ attributes: CFDictionary, 
                          _ error: UnsafeMutablePointer<Unmanaged<CFError>?>?) -> SecKey?

第二個參數 attributes 需要的是密鑰種類 (RSA 還是 EC),密鑰類型 (公鑰還是私鑰),密鑰尺寸 (數據 bit 數) 等信息,比較簡單。

關于所需要的數據格式,根據密鑰種類不同,而有所區別。在這個風馬牛不相及的頁面 以及 SecKey 源碼 的注釋中有所提及:

The method returns data in the PKCS #1 format for an RSA key. For an elliptic curve public key, the format follows the ANSI X9.63 standard using a byte string of 04 || X || Y. … All of these representations use constant size integers, including leading zeros as needed.

The requested data format depend on the type of key (kSecAttrKeyType) being created:

kSecAttrKeyTypeRSA               PKCS#1 format, public key can be also in x509 public key format
kSecAttrKeyTypeECSECPrimeRandom  ANSI X9.63 format (04 || X || Y [ || K])

JWA - RSA

簡單說,RSA 的公鑰需要遵守 PKCS#1,使用 X.509 編碼即可。所以對于 RSA 的 JWK 里的 ne,我們用 DER 按照 X.509 編碼成序列后,就可以扔給 Security 框架了:

extension JWK {
    struct RSA {
        let modulus: String
        let exponent: String
    }
}

let jwk: JWK.RSA = ...
guard let n = jwk.modulus.base64URLDecoded else { ... }
guard let e = jwk.exponent.base64URLDecoded else { ... }

var modulusBytes = [UInt8](n)            
if let firstByte = modulusBytes.first, firstByte >= 0x80 {
    modulusBytes.insert(0x00, at: 0)
}
let exponentBytes = [UInt8](e)

let modulusEncoded = modulusBytes.encode(as: .integer)
let exponentEncoded = exponentBytes.encode(as: .integer)
let sequenceEncoded = (modulusEncoded + exponentEncoded).encode(as: .sequence)

let data = Data(bytes: sequenceEncoded)

關于 DER 編碼部分的代碼,可以在這里找到。對于 modulusBytes,首位大于等于 0x80 時需要追加 0x00 的原因,也已經在第一篇中提及。如果你不知道我在說什么,建議回頭仔細再看一下前兩篇的內容。

使用上面的 data 就可以獲取 RSA 的公鑰了:

let sizeInBits = data.count * MemoryLayout<UInt8>.size
let attributes: [CFString: Any] = [
    kSecAttrKeyType: kSecAttrKeyTypeRSA,
    kSecAttrKeyClass: kSecAttrKeyClassPublic,
    kSecAttrKeySizeInBits: NSNumber(value: sizeInBits)
]
var error: Unmanaged<CFError>?
guard let key = SecKeyCreateWithData(data as CFData, attributes as CFDictionary, &error) else {
    // 錯誤處理
}
print(key)

// 一切正常的話,打印類似這樣:
// <SecKeyRef algorithm id: 1, key type: RSAPublicKey, version: 4, 
// block size: 1024 bits, exponent: {hex: 10001, decimal: 65537}, 
// modulus: DD95AB518D18E8828DD6A238061C51D82EE81D516018F624..., 
// addr: 0x6000027ffb00>

JWA - ECSDA

按照說明,對于 EC 公鑰,期望的數據是符合 X9.63 中未壓縮的橢圓曲線點座標:04 || X || Y。不過,雖然在文檔說明里提及:

All of these representations use constant size integers, including leading zeros as needed.

但事實是 SecKeyCreateWithData 并不喜歡在首位追加 0x00 的做法。這里的 XY 必須是滿足橢圓曲線對應要求的密鑰位數的整數值,如果在首位大于等于 0x80 的值前面追加 0x00,反而會導致無法創建 SecKey。所以,在組織數據時,不僅不需要添加 0x00,我們反而最好檢查一下獲取的 JWK,如果首位有不必要的 0x00 的話,應該將其去除:

extension JWK {
    struct RSA {
        let x: String
        let y: String
    }
}

let jwk: JWK.RSA = ...
guard let decodedXData = jwk.x.base64URLDecoded else { ... }
guard let decodedYData = jwk.y.base64URLDecoded else { ... }

let xBytes: [UInt8]
if decodedXData.count == curve.coordinateOctetLength {
    xBytes = [UInt8](decodedXData)
} else {
    xBytes = [UInt8](decodedXData).dropFirst { $0 == 0x00 }
}

let yBytes: [UInt8]
if decodedYData.count == curve.coordinateOctetLength {
    yBytes = [UInt8](decodedYData)
} else {
    yBytes = [UInt8](decodedYData).dropFirst { $0 == 0x00 }
}

let uncompressedIndicator: [UInt8] = [0x04]
let data = Data(bytes: uncompressedIndicator + xBytes + yBytes)

創建公鑰時和 RSA 類似:

let sizeInBits = data.count * MemoryLayout<UInt8>.size
let attributes: [CFString: Any] = [
    kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
    kSecAttrKeyClass: kSecAttrKeyClassPublic,
    kSecAttrKeySizeInBits: NSNumber(value: sizeInBits)
]
var error: Unmanaged<CFError>?
guard let key = SecKeyCreateWithData(data as CFData, attributes as CFDictionary, &error) else {
    // 錯誤處理
}
print(key)

// 一切正常的話,打印類似這樣:
// <SecKeyRef curve type: kSecECCurveSecp256r1, algorithm id: 3, 
// key type: ECPublicKey, version: 4, block size: 256 bits, 
// y: 3D4F8B27B29E5C77FCF877367245F3D75C2FBA806C54A0A0C05807E1B536E68A, 
// x: FFB00CF903B79BB0F6C049208A59C448049BE0A2A1AF4692C486085CBD9057EF, 
// addr: 0x7fcafd80ced0>

驗證簽名

Security 框架中為使用公鑰進行簽名驗證準備了一個方法:SecKeyVerifySignature

func SecKeyVerifySignature(_ key: SecKey, 
                         _ algorithm: SecKeyAlgorithm, 
                         _ signedData: CFData, 
                         _ signature: CFData, 
                         _ error: UnsafeMutablePointer<Unmanaged<CFError>?>?) -> Bool

key 我們已經拿到了,signedData 就是之前我們準備的 {Header}.{Payload} 的字符串的數據表示 (也就是 plainText.data(using: .ascii)。注意,這里的 plainText 不是一個 Base64Url 字符串,JWS 簽名所針對的就是這個拼湊后的字符串的散列值)。我們需要為不同的簽名算法指定合適的 SecKeyAlgorithm,通過訪問 SecKeyAlgorithm的靜態成員,就可以獲取 Security 框架預先定義的算法了。比如常用的:

let ecdsa256 = SecKeyAlgorithm.ecdsaSignatureMessageX962SHA256
let rsa256 = SecKeyAlgorithm.rsaSignatureDigestPKCS1v15SHA256

你可以在 Apple 的文檔里找到所有支持的算法的定義,但是不幸的是,這些算法都只有名字,沒有具體說明,也沒有使用范例。想要具體知道某個算法的用法,可能需要在源碼級別去參考注釋。為了方便,對于簽名驗證相關的一些常用算法,我列了一個表說明對應關系:

算法 輸入數據 (signedData) 簽名 (signature) 對應 JWT 算法
rsaSignatureDigestPKCS1v15SHA{x} 原數據的 SHA-x 摘要 PKCS#1 v1.5 padding 的簽名 RS{x}
rsaSignatureMessagePKCS1v15SHA{x} 原數據本身,框架負責計算 SHA-x 摘要 PKCS#1 v1.5 padding 的簽名 RS{x}
rsaSignatureDigestPSSSHA{x} 原數據的 SHA-x 摘要 使用 PSS 的 PKCS#1 v2.1 簽名 PS{x}
rsaSignatureMessagePSSSHA{x} 原數據本身,框架負責計算 SHA-x 摘要 使用 PSS 的 PKCS#1 v2.1 簽名 PS{x}
ecdsaSignatureDigestX962SHA{x} 原數據的 SHA-x 摘要 DER x9.62 編碼的 r 和 s ES{x}
ecdsaSignatureMessageX962SHA{x} 原數據本身,框架負責計算 SHA-x 摘要 DER x9.62 編碼的 r 和 s ES{x}

不難看出,這些簽名算法基本就是 {算法類型} + {數據處理方式} + {簽名格式} 的組合。另外還有一些更為泛用的簽名算法,像是 .ecdsaSignatureRFC4754 或者 .rsaSignatureRaw,你需要按照源碼注釋給入合適的輸入,不過一般來說還是直接使用預設的散列的 __Message__SHA___ 這類算法最為方便。

SecKeyAlgorithm 中除了簽名算法,也包括了使用 RSA 和 EC 進行加密的相關算法。整體上和簽名算法的命名方式類似,有興趣和需要相關內容的同學可以自行研究。

對于 JWT 來說,RS 算法的簽名已經是 PKCS#1 v1.5 padding 的了,所以直接將 signedDatasignature 配合使用 rsaSignatureMessagePKCS1v15SHA{x} 就可以完成驗證。

var error: Unmanaged<CFError>?
let result = SecKeyVerifySignature(
    key, 
    .rsaSignatureMessagePKCS1v15SHA256, 
    signedData as CFData, 
    signature as CFData, &error)

對于 ES 的 JWT 來說,事情要麻煩一些。我們收到的 JWT 里的簽名只是 {r, s} 的簡單連接,所以需要預先進行處理。按照 X9.62 中對 signature 的編碼定義:

ECDSA-Sig-Value ::= SEQUENCE {
    r INTEGER,
    s INTEGER }

因此,在調用 SecKeyVerifySignature 之前,先處理簽名:

let count = signature.count
guard count != 0 && count % 2 == 0 else {
    // 錯誤,簽名應該是兩個等長的整數
}
var rBytes = [UInt8](signature[..<(count / 2)])
var sBytes = [UInt8](signature[(count / 2)...])

// 處理首位,我們已經做過很多次了。
if rBytes.first! >= UInt8(0x80) {
    rBytes.insert(0x00, at: 0)
}

if sBytes.first! >= UInt8(0x80) {
    sBytes.insert(0x00, at: 0)
}

// 完成簽名的 DER 編碼
let processedSignature = Data(bytes: 
    (rBytes.encode(as: .integer) + sBytes.encode(as: .integer))
    .encode(as: .sequence))

var error: Unmanaged<CFError>?
let result = SecKeyVerifySignature(
    key, 
    .ecdsaSignatureMessageX962SHA256, 
    signedData as CFData, 
    processedSignature as CFData, &error)

上面 RSA 和 ECDSA 的驗證,都假設了使用 SHA-256 作為散列算法。如果你采用的是其他的散列算法,記得替換。

驗證 Payload 內容

簽名正確完成驗證之后,我們就可以對 JWT Payload 里的內容進行驗證了:包括但不限于 “iss”,”sub”,”exp”,”iat” 這些保留值是否正確。當簽名和內容都驗證無誤后,就可以安心使用這個 JWT 了。

一些問題

至此,我們從最初的 JWT 定義開始,引伸出 JWA,JWK 等一系列 JOSE 概念。然后我們研究了互聯網安全領域的通用編碼方式和幾種最常見的密鑰的構成。最后,我們使用這些知識在 Security 框架的幫助下,完成了 JWT 的簽名驗證的整個流程。

事后看上去沒有太大難度,但是由于涉及到的名詞概念很多,相關標準錯綜復雜,因此初上手想要把全盤都弄明白,還是會有一定困難。希望這系列文章能夠幫助你在起步階段就建立相對清晰的知識體系,這樣在閱讀其他的相關信息時,可以對新的知識進行更好的分類整理。

最后,是一些我自己在學習和實踐中的考慮。在此一并列出,以供參考。如果您有什么指正和補充,也歡迎留言評論。

為什么不用已有的相關開源框架

現存的和這個主題相關的 iOS 或者 Swift 框架有一些,比如 JOSESwiftJSONWebToken.swiftSwift-JWTvaper/jwt 等等。來回比較考察,它們現在 (2018 年 12 月) 或多或少存在下面的不足:

  • 沒有一個從 JWK 開始到 JWT 的完整方案。JWT 相關的框架基本都是從本地證書獲取公鑰進行驗證,而我需要從 JWK 獲取證書
  • 支持 JWK 的框架只實現了部分算法,比如只有 RSA,沒有 ECDSA 支持。
  • 一些框架依賴關系太復雜,而且大部分實現是面向 Swift Server Side,而非 iOS 的。

LINE SDK 中,我們需要,且只需要在 iOS 上利用 Security 框架完成驗證。同時 Server 可能會變更配置,所以我們需要同時支持 RSA 和 ECDSA (當前默認使用 ECDSA)。另外,本身作為一個提供給第三方開發者的 SDK,我們不允許引入不可靠的復雜依賴關系 (最理想的情況是零依賴,也就是 LINE SDK 的現狀)。基于這些原因,我沒有使用現有的開源代碼,而是自己從頭進行實現。

為什么不把你做的相關內容整理開源

在 LINE SDK 中的方案是不完備的,它是 JOSE 中滿足我們的 JWT 解析和驗證需求的最小子集,因此沒有很高的泛用性,不適合作為單獨項目開源。不過因為 LINE SDK 整個項目是開源的,JOSE 部分的代碼其實也都是公開且相對獨立的。如果你感興趣,可以在 LINE SDK 的 Crypto 文件夾下找到所有相關代碼。

為什么要用非對稱算法,各算法之間有什么優劣

不少 JWT 使用 HS 的算法 (HMAC)。和 RSA 或 ECDSA 不同,HMAC 是對稱加密算法。對稱算法加密和解密比較簡單,因為密鑰相同,所以比較適合用在 Server to Server 這種雙方可信的場合。如果在客戶端上使用對稱算法,那就需要將這個密鑰存放在客戶端上,這顯然是不可接受的。對于 Client - Server 的通訊,非對稱算法應該是毋庸置疑的選擇。

相比與 RSA,ECDSA 可以使用更短的密鑰實現和數倍長于自己的 RSA 相同的安全性能。

For example, at a security level of 80 bits (meaning an attacker requires a maximum of about 2^80 operations to find the private key) the size of an ECDSA public key would be 160 bits, whereas the size of a DSA public key is at least 1024 bits.

由于 ECDSA 是專用的 DSA 算法,只能用于簽名,而不能用作加密和密鑰交換,所以它比 RSA 要快很多。另外,更小的密鑰也帶來了更小的計算量。這些特性對于減少 Server 負擔非常重要。關于 ECDSA 的優勢和它相對于 RSA 的對比,可以參考 Cloudflare 的這篇文章

簽名的安全性

JWT 簽名的偽造一直是一個困擾人的問題。因為 JWT 的 Header 和 Payload 內容一旦確定的話,它的簽名也就確定了 (雖然 ECDSA 會產生隨機數使簽名每次都不同,但是這些簽名都可以通過驗證)。這帶來一個問題,攻擊者可以通過截取以前的有效的 JWT,然后把它作為新的響應發給用戶。這類 JWT 依然可以正確通過簽名驗證。

因此,我們必須每次生成不同的 JWT,來防止這種替換攻擊。最簡單的方式就是在內存中存儲隨機值,發送 JWT 請求時附帶這個隨機值,然后 Server 將這個隨機值嵌入在返回的 JWT 的 Payload 中。Client 收到后,再與內存中保存的值進行比對。這樣保證了每次返回的 JWT 都不相同,讓簽名驗證更加安全。

OpenSSL 版本的問題

macOS 上自帶的 OpenSSL 版本一般比較舊,而大部分 Linux 系統的 OpenSSL 更新一些。不同版本的 OpenSSL (或者其他的常用安全框架) 實現細節上會有差異,比如有些版本會在負數首位補 0x00 等。在測試時,最好讓 Server 的小伙伴確認一下使用的 OpenSSL 版本,這樣能在驗證和使用密鑰上避免一些不必要的麻煩。(請不要問我細節!都是淚)

JWT 可以用來做什么,應該用來做什么

JWT 最常見的使用場景有兩個:

  • 授權:用戶登錄后,在后續的請求中帶上一個有效的 JWT,其中包含該用戶可以訪問的路徑或權限等。服務器驗證 JWT 有效性后對訪問進行授權。相比于傳統像是 OAuth 的 token 來說,服務器并不需要存儲這些 token,可以實現無狀態的授權,因此它的開銷較小,也更容易實現和理解。另外,由于 JWT 不需要依賴 Cookie 的特性,跨站或者跨服務依然可能使用,這讓單點登錄非常簡單。
  • 信息交換:LINE SDK 中對用戶信息進行簽名和驗證,就屬于信息交換的范疇。依賴 JWT 的簽名特性,接收方可以確保 JWT 中的內容沒有被篡改,是一種安全的信息交換方式。

最近有非常多的關于反對使用 JWT 進行授權的聲音,比如這篇文章這篇文章。JWT 作為授權 token 來使用,最大的問題在于無法過期或者作廢,另外,一些嚴格遵守標準的實現,反而可能引入嚴重的安全問題

不過對于第二種用法,也就是信息交換來說,JWT 所提供的便捷和安全性是無人質疑的。

我也想讀讀看相關標準

如你所愿,我整理了一下涉及到的標準。祝武運昌隆!

關于編碼和算法
關于 JOSE
雜項

驗證和速查工具匯總

你的這篇文章或者代碼好像有問題!

我是初學者,文章中的紕漏請不吝賜教指出!

關于代碼方面的不足,LINE SDK 歡迎各種 PR。但是如果您發現的問題涉及安全漏洞,或者會導致比較嚴重后果的話,還請先不要公開公布。如果能按照這里的說明給我們發送郵件聯系的話,實在感激不盡。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,646評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,595評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,560評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,035評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,814評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,224評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,301評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,444評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,988評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,804評論 3 355
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,998評論 1 370
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,544評論 5 360
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,237評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,665評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,927評論 1 287
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,706評論 3 393
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,993評論 2 374

推薦閱讀更多精彩內容